Remix is a full-stack React framework with APIs that support server rendering, data loading, and routing. It uses the Web Fetch API to enable fast page loads and seamless transitions between several sites, and it can run anywhere.
Today, we will learn how to manage user authentication in Remix in this tutorial. We’ll create a quote wall application where authenticated users can view and publish quotes, while unauthenticated users can just view the posts and have no ability to post.
To follow along with this article, you need to have the following:
To get started, it’s important to choose Just the basics, Remix App Server, and then TypeScript when prompted.
Let’s scaffold a basic Remix application with the following command:
npx create-remix@latest remix-quote-wall cd remix-quote-wall npm run dev
To add Tailwind to our Remix app, let’s do the following:
Install tailwindcss
, its peer dependencies, and concurrently
via npm
, and then run the init command to generate both tailwind.config.js
and postcss.config.js
:
npm install -D tailwindcss postcss autoprefixer concurrently npx tailwindcss init -p
Add the paths to all of your template files in your tailwind.config.js
file:
//tailwind.config.js module.exports = { content: [ "./app/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }
package.json
scriptsUpdate the scripts in your package.json
file to build both your development and production CSS:
... { "scripts": { "build": "npm run build:css && remix build", "build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css", "dev": "concurrently \"npm run dev:css\" \"remix dev\"", "dev:css": "tailwindcss -w -o ./app/styles/app.css", } } ...
Create a ./styles/app.css
file and add the @tailwind
directives for each of Tailwind’s layers:
/*app.css*/ @tailwind base; @tailwind components; @tailwind utilities;
Import the compiled ./app/styles/app.css
file in your ./app/root.jsx
file by adding the following:
... import styles from "~/styles/app.css" export function links() { return [{ rel: "stylesheet", href: styles }] } ...
Now, let’s run our development server with the following command:
npm run dev
root.jsx
Let’s add some Tailwind classes to the app component by replacing the contents of app/root.jsx
with this:
// app/root.jsx import { Links, LiveReload, Meta, Outlet } from "@remix-run/react"; import styles from "./styles/app.css" export function links() { return [{ rel: "stylesheet", href: styles }] } export const meta = () => ({ charset: "utf-8", title: "Quote Wall App", viewport: "width=device-width,initial-scale=1", }); export default function App() { return ( <html lang="en"> <head> <Meta /> <Links /> </head> <body className="bg-purple-100 relative px-5"> <div className="mt-20 w-full max-w-screen-lg mx-auto"> <Outlet /> </div> <LiveReload /> </body> </html> ); }
After that, let’s set up our route structure. We are going to have a few routes:
/ /login /new-quote
Let’s start with the index route (/
). To do that, create a file app/routes/index.tsx
and add the following to it:
export default function Index() { return ( <div> <div className="grid lg:grid-flow-row grid-cols-1 lg:grid-cols-3"> Hello </div> </div> ) }
Then, let’s update our app/root.tsx
file with the following:
import { Link, Links, LiveReload, Meta, Outlet } from "@remix-run/react"; import styles from "./styles/app.css" export function links() { return [{ rel: "stylesheet", href: styles }] } export const meta = () => ({ charset: "utf-8", title: "Quote Wall App", viewport: "width=device-width,initial-scale=1", }); export default function App() { return ( <html lang="en"> <head> <Meta /> <Links /> </head> <body className="bg-purple-100 relative px-5"> <div className="mt-20 w-full max-w-screen-lg mx-auto"> <Outlet /> </div> <LiveReload /> </body> </html> ); }
Our app is getting in shape! We will create the missing routes as we progress.
Let’s update our app/routes/index.jsx
file with the navigation segment as follows:
import { Link } from "@remix-run/react"; export default function Index() { return ( <div> <nav className="bg-gradient-to-br from-purple-400 via-purple-500 to-purple-500 w-full fixed top-0 left-0 px-5"> <div className="w-full max-w-screen-lg mx-auto flex justify-between content-center py-3 "> <Link className="text-white text-3xl font-bold" to={"/"}>Quote Wall</Link> <div className="flex flex-col md:flex-row items-center justify-between gap-x-4 text-blue-50"> <Link to={"login"}>Login</Link> <Link to={"login"}>Register</Link> <Link to={"new-quote"}>Add A Quote</Link> <Link to={"logout"}>Logout</Link> </div> </div> </nav> </div> ) }
Since we won’t be using our quote component in more than one file, we’ll add it in the app/routes/index.jsx
file.
Let’s update our app/routes/index.jsx
file with the quote segment as follows:
... export default function Index() { return ( <div> <nav>...</nav> <div className="grid lg:grid-flow-row grid-cols-1 lg:grid-cols-3"> <figure className="m-4 py-10 px-4 shadow-md shadow-sky-100"> <blockquote cite="https://wisdomman.com" className="py-3"> <p className="text-gray-800 text-xl"> A stitch in Time saves Nine. </p> </blockquote> <figcaption> <cite className="text-gray-600 text-md mb-4 text-right"> - Unknown </cite> </figcaption> </figure> </div> </div> ) }
Let’s add some additional quotes to our program using dummy data.
We’ll need to write a loader function to assist with data loading and provisioning. Add the following to our app/routes/index.jsx
file:
// app/routes/index.jsx import { Link, useLoaderData } from "@remix-run/react"; import { json } from "@remix-run/node"; export const loader = async () => { return json({ quotes: [ { quote: 'Light at the end of the tunnel, dey don cut am.', by: 'Brain Jotter' }, { quote: 'Promised to stand by you, we don sit down.', by: 'Brain Jotter' }, { quote: 'Polythecnic wey dey in Italy, Napoli.', by: 'Comrade with wisdom and Understanding' } ] }) }; ...
Here, we import the useLoaderData
from '@remix-run/react
Hook so we can access the provided data from the loader function. Also, we import { json } from ‘@remix-run/node
in order to return data in JSON format.
Now, let’s populate the page with the quotes. With the data provided, let’s populate it on the page with the map
function:
// app/routes/index.jsx ... export default function Index() { const { quotes } = useLoaderData(); return ( <div> <nav>...</nav> <div className="grid lg:grid-flow-row grid-cols-1 lg:grid-cols-3"> { quotes.map((q, i) => { const { quote, by } = q; return ( <figure key={i} className="m-4 py-10 px-4 shadow-md shadow-sky-100"> <blockquote cite="https://wisdomman.com" className="py-3"> <p className="text-gray-800 text-xl"> {quote} </p> </blockquote> <figcaption> <cite className="text-gray-600 text-md mb-4 text-right"> - {by} </cite> </figcaption> </figure> ) }) } </div> </div> ) }
Data persistence is required in almost all real-world applications. We’d like to save our quotes to a database so that others can read them and possibly submit their own.
We’ll utilize our own SQLite database in this article. It’s basically a database that sits in a file on your computer, is remarkably capable, and best of all is supported by Prisma. If you’re not sure which database to utilize, this is an excellent place to start.
To get started, we’ll have to install the following packages:
prisma
for interacting with our database and schema during development@prisma/client
for making queries to our database during runtimeInstall the Prisma packages:
npm install --save-dev prisma npm install @prisma/client
And we can now initialize Prisma with SQLite:
npx prisma init --datasource-provider sqlite
The following is what you will get:
✔ Your Prisma schema was created at prisma/schema.prisma You can now open it in your favorite editor. warn You already have a .gitignore. Don't forget to exclude .env to not commit any secret. Next steps: 1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started 2. Run prisma db pull to turn your database schema into a Prisma schema. 3. Run prisma generate to generate the Prisma Client. You can then start querying your database. More information in our documentation:Get started with Prisma
Build data-driven applications with ease using Prisma ORM, add connection pooling or global caching with Prisma Accelerate or subscribe to database changes in real-time with Prisma Pulse.
We should notice new files and folders like prisma/schema.prisma
after running the command above.
Our prisma/schema.prisma
should look like this:
// prisma/schema.prisma // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") }
Now that Prisma is installed and set up, let’s begin modeling our app.
Update prisma/schema.prisma
with the following:
// prisma/schema.prisma // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Quote { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt by String quote String }
With this, let’s run the following command:
npx prisma db push
The command’s output will be as follows:
Environment variables loaded from .env Prisma schema loaded from prisma/schema.prisma Datasource "db": SQLite database "dev.db" at "file:./dev.db" SQLite database dev.db created at file:./dev.db 🚀 Your database is now in sync with your schema. Done in 158ms ✔ Generated Prisma Client (3.14.0 | library) to ./node_modules/@prisma/client in 1.44s
What occurred was that our database prisma/dev.db
was created first, and then all of the essential changes were made to the database to reflect the schema that we specified in the (prisma/schema.prisma
) file. Finally, it built Prisma’s TypeScript types so that when we use its API to connect with the database, we’ll get fantastic autocomplete and type checking.
Next, let’s add that prisma/dev.db
and .env
to our .gitignore
so we don’t commit them to our GitHub repository:
node_modules .output .cache /.cache /build/ /public/build /api/index.js `
In case your database gets messed up, you can delete the prisma/dev.db
file and run the npx prisma db push
command again.
This is how we connect our app with the database. We’ll add the following at the top of our prisma/seed.ts file
, which we’ll create later as we progress:
import { PrismaClient } from "@prisma/client"; const db = new PrismaClient();
While this works perfectly, we don’t want to have to shut down and restart our server every time we make a server-side modification during development. As a result, @remix-run/serve
rebuilds the code from the ground up.
The problem with this strategy is that every time we alter the code, we’ll create a new database connection, and we’ll soon run out of connections. With database-accessing apps, this is a prevalent problem. As a result, Prisma issues a caution:
Warning: 10 Prisma Clients are already running
To avoid this development time problem, we’ve got a little bit of extra work to do.
Create a new file app/utils/db.server.ts
and paste the following code into it:
import { PrismaClient } from "@prisma/client"; let db: PrismaClient; declare global { var __db: PrismaClient | undefined; } // this is needed because in development we don't want to restart // the server with every change, but we want to make sure we don't // create a new connection to the DB with every change either. if (process.env.NODE_ENV === "production") { db = new PrismaClient(); } else { if (!global.__db) { global.__db = new PrismaClient(); } db = global.__db; } export { db };
The file naming convention is one thing I’d like to point out. The .server
piece of the filename tells Remix that this code should never be displayed in a browser. This isn’t required because Remix does a fantastic job at keeping server code out of the client.
However, because some server-only dependencies can be difficult to tree shake, appending .server
to the filename tells the compiler to ignore this module and its imports when bundling for the browser. For the compiler, the .server
works as a sort of barrier.
Let’s create a new file called prisma/seed.ts
and paste the following code snippet:
import { PrismaClient } from "@prisma/client"; const db = new PrismaClient(); async function seed() { await Promise.all( getQuotes().map((quote) => { return db.quote.create({ data: quote }) }) ) } seed(); function getQuotes() { return [ { quote: 'The greatest glory in living lies not in never falling, but in rising every time we fall.', by: 'Nelson Mandela' }, { quote: 'The way to get started is to quit talking and begin doing.', by: 'Walt Disney' }, { quote: "Your time is limited, so don't waste it living someone else's life. Don't be trapped by dogma – which is living with the results of other people's thinking.", by: 'Steve Jobs' }, { quote: "If life were predictable it would cease to be life, and be without flavor.", by: 'Eleanor Roosevelt' }, { quote: "If you look at what you have in life, you'll always have more. If you look at what you don't have in life, you'll never have enough.", by: 'Oprah Winfrey' }, { quote: "If you set your goals ridiculously high and it's a failure, you will fail above everyone else's success.", by: 'James Cameron' }, { quote: "Life is what happens when you're busy making other plans.", by: 'John Lennon' } ] }
You are welcome to contribute new quotes to this list.
We must now run this program in order to seed our database with dummy quotes.
Install esbuild-register
as a dev dependency in order to run the seed file:
npm install --save-dev esbuild-register
And now we can run our seed.ts
file with the following command:
node --require esbuild-register prisma/seed.ts
The dummy quotes have now been seeded into our database.
We don’t need to run the above command each time we reset the database, so we’ll put it to our package.json
file:
// ... "prisma": { "seed": "node --require esbuild-register prisma/seed.ts" }, "scripts": { // ...
Now, any time we reset the database, Prisma will call our seed file as well.
Our aim is to put a list of the quotes on the /
route
A loader
is used to load data into a Remix route module. This is essentially an async
function you export that returns a response and is accessed via the useLoaderData
hook on the component.
Let’s make some changes to app/route/index.tsx
:
... import { db } from "~/utils/db.server"; export const loader = async () => { return json({ quotes: await db.quote.findMany() }) }; export default function Index() { const { quotes } = useLoaderData(); return ( <div> <nav></nav> <div className="grid lg:grid-flow-row grid-cols-1 lg:grid-cols-3"> { quotes.map((q, i) => { const { id, quote, by } = q; return ( <figure key={id} className="m-4 py-10 px-4 shadow-md shadow-sky-100"> <blockquote className="py-3"> <p className="text-gray-800 text-xl"> {quote} </p> </blockquote> <figcaption> <cite className="text-gray-600 text-md mb-4 text-right"> - {by} </cite> </figcaption> </figure> ) }) } </div> </div> ) }
Run npm run dev
and here is what you will get:
Let’s build a means to add new quotes to the database now that we’ve been able to display them from the database storage, shall we?
Create app/routes/new-quote.tsx
file and add the following to the file:
const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg text-purple-900 outline-purple-300 `; export default function NewQuoteRoute() { return ( <div className="flex justify-center items-center content-center"> <div className="lg:m-10 my-10 md:w-2/3 lg:w-1/2 bg-gradient-to-br from-purple-500 via-purple-400 to-purple-300 font-bold px-5 py-6 rounded-md"> <form method="post"> <label className="text-lg leading-7 text-white"> Quote Master (Quote By): <input type="text" className={inputClassName} name="by" required /> </label> <label className="text-lg leading-7 text-white"> Quote Content: <textarea required className={`${inputClassName} resize-none `} id="" cols={30} rows={10} name="quote"></textarea> </label> <button className="my-4 py-3 px-10 text-purple-500 font-bold border-4 hover:scale-105 border-purple-500 rounded-lg bg-white" type="submit">Add</button> </form> </div> </div> ) }
Here is the what the form page looks like:
Let’s update the app/routes/new-quote.tsx
file with the following in order to submit data.
import { redirect } from "@remix-run/node"; import { db } from "~/utils/db.server"; export const action = async ({ request }) => { const form = await request.formData(); const by = form.get('by'); const quote = form.get('quote'); if ( typeof by !== "string" || by === "" || typeof quote !== "string" || quote === "" ) { redirect('/new-quote') throw new Error(`Form not submitted correctly.`); } const fields = { by, quote }; await db.quote.create({ data: fields }); return redirect('/'); } ...
The action method is called for POST
, PATCH
, PUT
, and DELETE
HTTP methods, and it is used to edit or mutate data. The request attribute gives us access to the form data so we can validate it and submit the request to the server.
We can now add quotes, which will take us to the main page, which will display the new quotes we’ve added.
Notice how we processed the form submission without needing to use any React Hooks.
Remix Auth is an all-in-one, open source authentication solution for Remix.run apps. It is inspired by Passport.js, but based on the Web Fetch API, and requires a minimal set up with full server-side authentication and complete TypeScript support.
Remix Auth supports social strategy-based authentication and allows for the implementation of custom authentication strategies.
Remix Auth is often a good choice for developers who need to perform complex authentication strategies, such as logging in with Google, LinkedIn, Twitter, or other social platforms. You can find a list of available strategies built by the remix-auth community here.
bcrypt is a JavaScript library for hashing and comparing passwords. bcrypt doesn’t support social strategy-based authentication, but it’s useful if you’re considering a simple login or registration strategy based on email or username and password.
bcrypt v5.0.0 has a wrap-around bug that truncates passwords longer than 255 characters, resulting in severely weakened passwords. However, this bug was fixed in v5.0.1, making it more secure. For this reason, only bcrypt versions 5.0.1 and above are recommended.
Let’s look at how we can handle authentication to restrict unregistered users from posting.
In this article, we’ll implement the traditional username/password authentication strategy.
We’ll be using bcryptjs
to hash our passwords so nobody will be able to reasonably brute-force their way into an account.
We’ll install the bcrypt library and its type definition as follows:
npm install bcryptjs npm install --save-dev @types/bcryptjs
We have to update the prisma/schema.prisma
file with the user model and it should look like this:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) createAt DateTime @default(now()) updatedAt DateTime @updatedAt username String @unique passwordHash String quotes Quote[] } model Quote { id String @id @default(uuid()) userId String addedBy User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt by String quote String }
Next, we’ll reset our database with this schema by running the following command:
npx prisma db push
Running this command will prompt you to reset the database, hit Y to confirm.
Next, we’ll update the seed function in our prisma/seed.ts
file as follows:
... async function seed() { // WisdomMan is a default user with the password 'twixrox' const wisdomMan = await db.user.create({ data: { username: "WisdomMan", // this is a hashed version of "twixrox" passwordHash: "$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u", }, }); await Promise.all( getQuotes().map((quote) => { const data = {userId:wisdomMan.id, ...quote} return db.quote.create({ data }) }) ) } seed(); ...
Here, we seed in a user with the username “WisdomMan” and the password hash of “twixrox”.” Also, we seed the database with all our dummy quotes.
We have to run the seed again with the following:
npx prisma db seed
action
Let’s create a /login
route by adding a app/routes/login.tsx
file with the following validation logics for our login and registration forms:
import { json } from "@remix-run/node"; function validateUsername(username: unknown) { if (typeof username !== "string" || username.length < 3) { return `Usernames must be at least 3 characters long`; } } function validatePassword(password: unknown) { if (typeof password !== "string" || password.length < 6) { return `Passwords must be at least 6 characters long`; } } function validateUrl(url: any) { console.log(url); let urls = ["/"]; if (urls.includes(url)) { return url; } return "/"; } const badRequest = (data: any) => json(data, { status: 400 } );
Here, we wrote some custom validation logic for username, password and the url.
Next, we’ll update the app/routes/login.tsx
file with the following JSX template:
import type { ActionFunction, LinksFunction, } from "@remix-run/node"; import { useActionData, Link, useSearchParams, } from "@remix-run/react"; import { db } from "~/utils/db.server"; ... const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg text-purple-900 outline-purple-300 `; export default function LoginRoute() { const actionData = useActionData(); const [searchParams] = useSearchParams(); return ( <div className="flex justify-center items-center content-center text-white"> <div className="lg:m-10 my-10 md:w-2/3 lg:w-1/2 bg-gradient-to-br from-purple-500 via-purple-400 to-purple-300 font-bold px-5 py-6 rounded-md"> <form method="post"> <h1 className="text-center text-2xl text-white">Login</h1> <input type="hidden" name="redirectTo" value={ searchParams.get("redirectTo") ?? undefined } /> <fieldset className="text-center "> <legend className="sr-only"> Login or Register? </legend> <label> <input type="radio" name="loginType" value="login" defaultChecked={ !actionData?.fields?.loginType || actionData?.fields?.loginType === "login" } />{" "} Login </label> <label> <input type="radio" name="loginType" value="register" defaultChecked={ actionData?.fields?.loginType === "register" } />{" "} Register </label> </fieldset> <label className="text-lg leading-7 text-white"> Username: <input type="text" className={inputClassName} name="username" required minLength={3} defaultValue={actionData?.fields?.username} aria-invalid={Boolean( actionData?.fieldErrors?.username )} aria-errormessage={ actionData?.fieldErrors?.username ? "username-error" : undefined } /> {actionData?.fieldErrors?.username ? ( <p className="text-red-500" role="alert" id="username-error" > {actionData.fieldErrors.username} </p> ) : null} </label> <label className="text-lg leading-7 text-white"> Password <input name="password" className={inputClassName} required defaultValue={actionData?.fields?.password} type="password" aria-invalid={ Boolean( actionData?.fieldErrors?.password ) || undefined } aria-errormessage={ actionData?.fieldErrors?.password ? "password-error" : undefined } /> {actionData?.fieldErrors?.password ? ( <p className="text-red-500" role="alert" id="password-error" > {actionData.fieldErrors.password} </p> ) : null} </label> <div id="form-error-message"> {actionData?.formError ? ( <p className="text-red-500" role="alert" > {actionData.formError} </p> ) : null} </div> <button className="my-4 py-2 px-7 text-purple-500 font-bold border-2 hover:scale-105 border-purple-500 rounded-lg bg-white" type="submit">Login</button> </form> </div> </div> ) }
Here, we use useSearchParams
to get the redirectTo
query parameter and putting that in a hidden input. This way, our action
can know where to redirect the user. We’ll use this to redirect a user to the login and registration page. We added some conditions to our JSX to display error messages in the form if any occurs.
Before creating our session helpers, let’s add session secret to our .env
file as follows:
SESSION_SECRET=secret
Let’s create a file called app/utils/session.server.ts
and add the following session helper functions:
import bcrypt from "bcryptjs"; import { createCookieSessionStorage, redirect, } from "@remix-run/node"; import { db } from "./db.server"; const sessionSecret = process.env.SESSION_SECRET; if (!sessionSecret) { throw new Error("SESSION_SECRET must be set"); } const storage = createCookieSessionStorage({ cookie: { name: "RJ_session", // normally you want this to be `secure: true` // but that doesn't work on localhost for Safari // https://web.dev/when-to-use-local-https/ secure: process.env.NODE_ENV === "production", secrets: [sessionSecret], sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 30, httpOnly: true, }, }); export async function createUserSession( userId: string, redirectTo: string ) { const session = await storage.getSession(); session.set("userId", userId); return redirect(redirectTo, { headers: { "Set-Cookie": await storage.commitSession(session), }, }); } function getUserSession(request: Request) { return storage.getSession(request.headers.get("Cookie")); }
Here, we create our session storage using the createCookieSessionStorage
method. The createUserSession
function gets the stored session and sets it to our unique user ID and sets the cookie to the request header. The getUser
function retrieves the user cookie from the request headers.
Next, we’ll add helper functions to retrieve users by their unique ID.
Add the following to the app/utils/session.server.ts
file:
... export async function getUserId(request: Request) { const session = await getUserSession(request); const userId = session.get("userId"); if (!userId || typeof userId !== "string") return null; return userId; } export async function getUser(request: Request) { const userId = await getUserId(request); if (typeof userId !== "string") { return null; } try { const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, username: true }, }); return user; } catch { throw logout(request); } }
Here, the getUserId
function retrieves the user id from the existing session while the getUser
function uses the retrieved user ID to query the database for a user with a matching ID. We’ll implement the logout session helper as we proceed.
Next, we’ll create a helper function to prevent unauthenticated users from creating quotes.
Add the following to the app/utils/session.server.ts
file:
export async function requireUserId( request: Request, redirectTo: string = new URL(request.url).pathname ) { const session = await getUserSession(request); const userId = session.get("userId"); if (!userId || typeof userId !== "string") { const searchParams = new URLSearchParams([ ["redirectTo", redirectTo], ]); throw redirect(`/login?${searchParams}`); } return userId; }
With the following implementation, users who are not signed in will be redirected to the login route whenever they try to create a quote.
Next, the login, register, and logout helper functions.
Add the following to the app/utils/session.server.ts
file:
... type LoginForm = { username: string; password: string; }; export async function register({ username, password, }: LoginForm) { const passwordHash = await bcrypt.hash(password, 10); const user = await db.user.create({ data: { username, passwordHash }, }); return { id: user.id, username }; } export async function login({ username, password, }: LoginForm) { const user = await db.user.findUnique({ where: { username }, }); if (!user) return null; const isCorrectPassword = await bcrypt.compare( password, user.passwordHash ); if (!isCorrectPassword) return null; return { id: user.id, username }; } export async function logout(request: Request) { const session = await getUserSession(request); return redirect("/login", { headers: { "Set-Cookie": await storage.destroySession(session), }, }); }
The register
function uses bcrypt.hash
to hash the password before we store it in the database and then return the user ID and username. The login
function query the database by username. If found, the bcrypt.compare
method is used to compare the password with the passwordhash then return the user id and username. The logout
function destroys the existing user session and redirects to the login route.
You should have a fair knowledge on how to handle form submission since we’ve done the same in the create new quote section.
Similarly, we’ll create an action method that will accept the request object, which is used to modify or mutate data on the server.
Now, let’s update the app/routes/login.tsx
file with the following:
import { createUserSession, login, register } from "~/utils/session.server"; ... export const action: ActionFunction = async ({ request }) => { const form = await request.formData(); const loginType = form.get("loginType"); const username = form.get("username"); const password = form.get("password"); const redirectTo = validateUrl( form.get("redirectTo") || "/" ); if ( typeof loginType !== "string" || typeof username !== "string" || typeof password !== "string" || typeof redirectTo !== "string" ) { return badRequest({ formError: `Form not submitted correctly.`, }); } const fields = { loginType, username, password }; const fieldErrors = { username: validateUsername(username), password: validatePassword(password), }; if (Object.values(fieldErrors).some(Boolean)) return badRequest({ fieldErrors, fields }); switch (loginType) { case "login": { const user = await login({ username, password }); console.log({ user }); if (!user) { return badRequest({ fields, formError: `Username/Password combination is incorrect`, }); } return createUserSession(user.id, redirectTo); } case "register": { const userExists = await db.user.findFirst({ where: { username }, }); if (userExists) { return badRequest({ fields, formError: `User with username ${username} already exists`, }); } const user = await register({ username, password }); if (!user) { return badRequest({ fields, formError: `Something went wrong trying to create a new user.`, }); } return createUserSession(user.id, redirectTo); } default: { return badRequest({ fields, formError: `Login type invalid`, }); } } };
Here, we wrote a control flow using the switch
statement for both login and register cases. For the login flow, if there’s no user, the fields and a formError
will be returned. If there is a user, we’ll create their session and redirect to /quotes
. For the register flow, we check if the user exists. If there’s no user, we’ll create one alongside a session and redirect to /
.
Let’s create a file called app/routes/logout.tsx
and add the following:
import type { ActionFunction, LoaderFunction } from "@remix-run/node"; import { redirect } from "@remix-run/node"; import { logout } from "~/utils/session.server"; export const action: ActionFunction = async ({ request }) => { return logout(request); }; export const loader: LoaderFunction = async () => { return redirect("/"); };
Update the app/routes/index.tsx
file with the following:
... import { getUser } from "~/utils/session.server"; export const loader = async ({ request }) => { const user = await getUser(request); return json({ quotes: await db.quote.findMany(), user }) }; export default function Index() { const { quotes, user } = useLoaderData(); return ( <div> <nav className="bg-gradient-to-br from-purple-400 via-purple-500 to-purple-500 w-full fixed top-0 left-0 px-5"> <div className="w-full max-w-screen-lg mx-auto flex justify-between content-center py-3 "> <Link className="text-white text-3xl font-bold" to={"/"}>Quote Wall</Link> <div className="flex flex-row items-center justify-between gap-x-4 text-blue-50"> { user ? ( <> <Link to={"new-quote"}>Add A Quote</Link> <form action="/logout" method="post"> <button type="submit" className="button"> Logout </button> </form> </>) : ( <> <Link to={"login"}>Login</Link> <Link to={"login"}>Register</Link> </> ) } </div> </div > </nav > <div className="grid lg:grid-flow-row grid-cols-1 lg:grid-cols-3"> ... </div> </div > ) }
Now that we are done with all the authentication logic, we’ll need to update routes/new-quote
so that only authenticated users can create new quotes.
Update the app/routes/new-quote.tsx
file with the following:
import { redirect, json } from "@remix-run/node"; import { db } from "~/utils/db.server"; import { requireUserId, getUser } from "~/utils/session.server"; import { Link, useLoaderData } from "@remix-run/react"; export const action = async ({ request }) => { const userId = await requireUserId(request); const form = await request.formData(); const by = form.get("by"); const quote = form.get("quote"); if ( typeof by !== "string" || by === "" || typeof quote !== "string" || quote === "" ) { redirect("/new-quote"); throw new Error(`Form not submitted correctly.`); } const fields = { by, quote }; await db.quote.create({ data: { ...fields, userId: userId }, }); return redirect("/"); }; export const loader = async ({ request }) => { const user = await getUser(request); return json({ user, }); };
Next, we’ll update our TSX template as follows:
... const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg text-purple-900 outline-purple-300 `; export default function NewQuoteRoute() { const { user } = useLoaderData(); return ( <> <nav className="bg-gradient-to-br from-purple-400 via-purple-500 to-purple-500 w-full fixed top-0 left-0 px-5"> <div className="w-full max-w-screen-lg mx-auto flex justify-between content-center py-3 "> <Link className="text-white text-3xl font-bold" to={"/"}> Quote Wall </Link> <div className="flex flex-row items-center justify-between gap-x-4 text-blue-50"> {user ? ( <> <Link to={"new-quote"}>Add A Quote</Link> <form action="/logout" method="post"> <button type="submit" className="button"> Logout </button> </form> </> ) : ( <> <Link to={"login"}>Login</Link> <Link to={"register"}>Register</Link> </> )} </div> </div> </nav> <div className="flex justify-center items-center content-center"> <div className="lg:m-10 my-10 md:w-2/3 lg:w-1/2 bg-gradient-to-br from-purple-500 via-purple-400 to-purple-300 font-bold px-5 py-6 rounded-md"> <form method="post"> <label className="text-lg leading-7 text-white"> Quote Master (Quote By): <input type="text" className={inputClassName} name="by" required /> </label> <label className="text-lg leading-7 text-white"> Quote Content: <textarea required className={`${inputClassName} resize-none `} id="" cols={30} rows={10} name="quote" ></textarea> </label> <button className="my-4 py-3 px-10 text-purple-500 font-bold border-4 hover:scale-105 border-purple-500 rounded-lg bg-white" type="submit" > Add </button> </form> </div> </div> </> ); }
Now, only authenticated users can create new quotes in our app while unauthenticated users will be redirected to the /login
route when they try to create a new quote. We can check out the final version of our project by spinning up our development server with the following command:
npm run dev
We have finally come to the end of this tutorial. We have taken a look at how to implement authentication on Remix apps, and we have successfully built a full-stack quote wall application with support for user authentication. There are so many ways this can be improved, and I can’t wait to see what you build next with Remix. Thanks for reading.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
One Reply to "Handling user authentication with Remix"
It would of been cool if you showed how to connect prisma to planetscale. The free tier is really generous and scalable.