Emmanuel John I'm a full-stack software developer, mentor, and writer. I am an open source enthusiast. In my spare time, I enjoy watching sci-fi movies and cheering for Arsenal FC.

Handling user authentication with Remix

20 min read 5651

Remix Logo

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.

Table of Contents

Prerequisites

To follow along with this article, you need to have the following:

  • Node.js 14 or greater
  • npm 7 or greater
  • Prior knowledge of HTML, CSS, JavaScript, React
  • Basic knowledge of Tailwind CSS

Setting up a quote wall app with Remix

Creating a new Remix app

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 [email protected] remix-quote-wall
cd remix-quote-wall
npm run dev

Setting up Tailwind

To add Tailwind to our Remix app, let’s do the following:

  1. Install Tailwind

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
  1. Configure your template paths

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: [],
}
  1. Update your package.json scripts

Update 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",
  }
}
...
  1. Add the Tailwind directives to your 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;
  1. Import CSS file

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

Setting up 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>
    );
}

Routes

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.

The navigation component

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>
    )
}

A quote segment

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>
  )
}

Setting up the database

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.

Setting up Prisma

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 runtime

Install 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:

Getting started

Getting started

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.

Connecting to the database

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.


More great articles from LogRocket:


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.

Fetching quotes from the database using the Remix loader

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:

Quote Wall

Creating new quotes

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:

Quote Wall Fields

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.

Authentication options: Remix Auth vs. bcrypt

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.

Username/password authentication

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

Form validation with 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.

Creating session helpers

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.

Processing login and register form submissions

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 /.

Making a logout feature

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

Conclusion

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.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Emmanuel John I'm a full-stack software developer, mentor, and writer. I am an open source enthusiast. In my spare time, I enjoy watching sci-fi movies and cheering for Arsenal FC.

Leave a Reply