DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Building a full stack app with Remix & Drizzle ORM: Register & Login users

Introduction

In this tutorial, we will dive into the implementation of user signup and login functionalities. Throughout the process, we will explore the usage of Remix loaders and actions, which play a crucial role in handling data and form submissions. Additionally, we will leverage conform, a form library that simplifies working with Remix forms, streamlining the development experience.

Credit for inspiring this tutorial series goes Sabin Adams, whose insightful tutorial series served as a valuable source of inspiration for this project.

Overview

Please note that this tutorial assumes a certain level of familiarity with React.js, Node.js, and working with ORMs. This tutorial covers -

  • Introduction to loaders and actions in Remix.
  • Implementation of user registration functionality.
  • Implementation of user registration form.
  • Implementation of user login functionality.
  • Implementation of logout functionality.

All the code for this tutorial can be found here

Step 1: Remix loaders and actions

Loaders in Remix are responsible for fetching data before rendering a page or component. They allow us to fetch data from external APIs, query databases, required for rendering -

import { LoaderFunction, json } from "remix";

export const loader: LoaderFunction = async () => {
  const response = await fetch("https://api.example.com/users");
  const userData = await response.json();
  return json({ userData });
};
Enter fullscreen mode Exit fullscreen mode

To access the data fetched by the loader function in our component, we can use the useLoaderData hook provided by Remix.

Actions in Remix handle user interactions, such as form submissions, button clicks, or any other user-triggered events. They allow us to perform server-side operations, like processing form data, validating input, and updating databases -

import { ActionFunction } from "remix";

export const action: ActionFunction = async ({ request, params }) => {
  const { username, password } = await request.body.json();
  // Perform authentication logic here

  return new Response("Login successful!");
};
Enter fullscreen mode Exit fullscreen mode

To access the data returned by the action function in our component, we can utilize the useActionData hook provided by Remix.

Step 2: Implementing User Signups - Queries & Sessions

When implementing the user sign-up functionality, we need to handle the necessary database queries. Here's an overview of the process:

  • First, we check if the provided email already exists in our database. If the email is already registered, we notify the user that the email is already in use.
  • If the email is not found, we proceed to create a new user record in the database. Before storing the password, we need to hash it for security purposes. In this tutorial, we will be using the bcryptjs library to hash the password.
npm install bcryptjs
npm install --save-dev @types/bcryptjs
Enter fullscreen mode Exit fullscreen mode

Under the services/users.server.ts file add these queries -

import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { db } from "~/drizzle/config.db.server";
import { users } from "~/drizzle/schemas/users.db.server";
import type { NewUser } from "~/drizzle/schemas/users.db.server";

export function userExists(email: string) {
  return db.select().from(users).where(eq(users.email, email));
}

export async function createUser(user: NewUser) {
  const salt = await bcrypt.genSalt(10);
  const passwordHash = await bcrypt.hash(user.password, salt);

  return db
    .insert(users)
    .values({
      email: user.email,
      password: passwordHash,
      firstName: user.firstName,
      lastName: user.lastName,
    })
    .returning();
}
Enter fullscreen mode Exit fullscreen mode

With Drizzle, writing SQL queries becomes easy and straightforward. The above code showcases how Drizzle simplifies the process by providing a convenient API to interact with the database.

In Remix, we utilize cookie sessions to store user information, such as the user ID. Upon login, we create a session, and upon logout, we destroy it. To facilitate this functionality, we need to implement specific functions to manage our sessions. Under service/sessions.server.ts paste -

import { createCookieSessionStorage, redirect } from "@remix-run/node";

const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
  throw new Error("SESSION_SECRET must be set");
}

export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "kudos-remix-drizzle",
    secure: process.env.NODE_ENV === "production",
    secrets: [sessionSecret],
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
  },
});

export function getUserSession(request: Request) {
  const cookie = request.headers.get("Cookie");
  return sessionStorage.getSession(cookie);
}

export async function createUserSession(userId: string, redirectTo: string) {
  const session = await sessionStorage.getSession();
  session.set("userId", userId);

  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await sessionStorage.commitSession(session),
    },
  });
}

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 requireUserLogin(
  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;
}

export async function logout(request: Request) {
  const session = await getUserSession(request);
  return redirect("/login", {
    headers: {
      "Set-Cookie": await sessionStorage.destroySession(session),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode
  • The sessionStorage is created using createCookieSessionStorage with specified configuration options for the session cookie.
  • The getUserSession function retrieves the user session from the request headers.
  • The createUserSession function creates a new user session by setting the "userId" in the session and redirecting to a specified URL, we will use this function for successful logins and signups.
  • The getUserId function retrieves the "userId" from the user session.
  • The requireUserLogin function checks if a user is logged in by checking the "userId" in the user session. If not logged in, it redirects to the login page.
  • The logout function destroys the user session and redirects to the login page.

Step 3: Working on the Register Page.

With Remix's file system routing, every file created in the routes folder seamlessly maps to a specific path in our application. For instance, a file named register.tsx will be automatically served at the /register path, providing a straightforward and intuitive way to define routes. We will use Conform for managing forms in Remix, from your terminal install -

npm install @conform-to/react @conform-to/zod zod
Enter fullscreen mode Exit fullscreen mode

With Conform, working with forms involves three stages:

  • defining the form schema using Zod,
  • validating the form against the schema in the action,
  • utilizing the useForm hook provided by Conform in the component for easy error handling, focus management, accessibility, and client-side validation. Under routes/register.tsx paste -
import { type ActionArgs, json } from "@remix-run/node";
import { Form, Link, useActionData, useNavigation } from "@remix-run/react";
import { z } from "zod";
import { parse } from "@conform-to/zod";
import { conform, useForm } from "@conform-to/react";

import { createUser, userExists } from "~/services/users.server";
import { createUserSession } from "~/services/sessions.server";
import { Button, InputField } from "~/components/atoms";
import { Layout } from "~/layouts/Layout";

const schema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  email: z.string().min(1, "Email is required.").email("Email is invalid"),
  password: z.string().min(1, "Password is required"),
});

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const submission = parse(formData, { schema });

  if (!submission.value || submission.intent !== "submit") {
    return json(submission, { status: 400 });
  }

  const [emailTaken] = await userExists(submission.value.email);

  if (emailTaken) {
    return json(
      {
        ...submission,
        error: {
          email: "User already exists for the given email.",
        },
      },
      { status: 400 }
    );
  }

  const [user] = await createUser(submission.value);

  return createUserSession(user.id, "/home");
}

export default function RegisterPage() {
  const lastSubmission = useActionData<typeof action>();
  const navigation = useNavigation();
  const [form, { firstName, lastName, email, password }] = useForm({
    id: "register",
    lastSubmission,
    shouldRevalidate: "onInput",
    onValidate({ formData }) {
      return parse(formData, { schema });
    },
  });

  return (
    <Layout>
      <div className="h-full justify-center items-center flex flex-col gap-y-4">
        <Link
          to="/login"
          className="absolute top-8 right-8 rounded-xl bg-yellow-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
        >
          Login
        </Link>
        <h2 className="text-5xl font-extrabold text-yellow-300">
          Welcome to Kudos!
        </h2>
        <div className="font-semibold text-slate-300">
          <p className="font-semibold text-slate-300">
            Register to get started!
          </p>
        </div>

        <Form
          method="POST"
          {...form.props}
          className="rounded-2xl bg-gray-200 p-6 w-96"
        >
          <InputField
            {...conform.input(firstName, {
              type: "text",
              ariaAttributes: true,
            })}
            label="First Name"
            error={firstName.error}
            errorId={firstName.errorId}
          />
          <InputField
            {...conform.input(lastName, { type: "text", ariaAttributes: true })}
            label="Last Name"
            error={lastName.error}
            errorId={lastName.errorId}
          />
          <InputField
            {...conform.input(email, { type: "email", ariaAttributes: true })}
            label="Email"
            error={email.error}
            errorId={email.errorId}
          />
          <InputField
            {...conform.input(password, {
              type: "password",
              ariaAttributes: true,
            })}
            label="Password"
            error={password.error}
            errorId={password.errorId}
          />

          <div className="w-full text-center">
            <Button
              disabled={
                navigation.state === "submitting" ||
                navigation.state === "loading"
              }
              type="submit"
            >
              Register
            </Button>
          </div>
        </Form>
      </div>
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The useForm hook will return everything you need to setup a form including the error and config of each input field.
  • The conform.input function is a helpful utility that returns the appropriate props for an input element, including ARIA attributes.
  • We use the useNavigation hook to determine the navigation state, specifically if the form is currently being submitted. When a user submits the form, we want to disable the submit button until the navigation to the new page is complete.
  • Remix ensures that the new page is not loaded until all the queries in its loaders are finished, ensuring data consistency. By disabling the submit button during navigation transitions, we provide a better user experience and prevent any potential issues.

Before we test signup functionality under app/layouts/Layout.tsx -

type LayoutProps = {
  children: React.ReactNode;
};

export function Layout(props: LayoutProps) {
  return (
    <div className="h-screen w-full bg-blue-600 font-mono">
      {props.children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

You might have noticed that after signup we navigate the user to "/home" page, lets create it app/routes/home.tsx -

import { Layout } from "~/layouts/Layout";

export default function HomePage() {
  return <Layout>Home Page</Layout>;
}
Enter fullscreen mode Exit fullscreen mode

Lets run npm run dev and test the signup, make sure it creates a new record in the users table also check for the cookie with name "kudos-remix-drizzle" under Browser/Applications tab. Also test for our validations, try submitting an empty form, wrong email, try to signup with email that already exists.

Step 4: Set up the Login Page

Now similarly we will create the login page. Under app/routes/login.tsx paste -

import bcrypt from "bcryptjs";
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Link, Form, useActionData, useNavigation } from "@remix-run/react";
import { z } from "zod";
import { parse } from "@conform-to/zod";
import { conform, useForm } from "@conform-to/react";

import { Button, InputField } from "~/components/atoms";
import { Layout } from "~/layouts/Layout";
import { userExists } from "~/services/users.server";
import { createUserSession, getUserId } from "~/services/sessions.server";

const schema = z.object({
  email: z.string().min(1, "Email is required.").email("Email is invalid"),
  password: z.string().min(1, "Password is required"),
});

export async function loader({ request }: LoaderArgs) {
  const userId = await getUserId(request);
  if (userId) return redirect("/");
  return null;
}

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const submission = parse(formData, { schema });

  if (!submission.value || submission.intent !== "submit") {
    return json(submission, { status: 400 });
  }

  const [user] = await userExists(submission.value.email);

  if (!user) {
    return json(
      {
        ...submission,
        error: {
          loginError: "User not found. Invalid email or password.",
        },
      },
      { status: 400 }
    );
  }

  const isPasswordCorrect = await bcrypt.compare(
    submission.value.password,
    user.password
  );

  if (!isPasswordCorrect) {
    return json(
      {
        ...submission,
        error: {
          loginError: "User not found. Invalid email or password.",
        },
      },
      { status: 400 }
    );
  }

  return createUserSession(user.id, "/home");
}

export default function LoginPage() {
  const lastSubmission = useActionData<typeof action>();
  const navigation = useNavigation();
  const [form, { email, password }] = useForm({
    id: "login",
    lastSubmission,
    shouldRevalidate: "onInput",
    onValidate({ formData }) {
      return parse(formData, { schema });
    },
  });

  return (
    <Layout>
      <div className="h-full justify-center items-center flex flex-col gap-y-4">
        <Link
          to="/register"
          className="absolute top-8 right-8 rounded-xl bg-yellow-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
        >
          Register
        </Link>
        <h2 className="text-5xl font-extrabold text-yellow-300">
          Welcome to Kudos!
        </h2>
        <div className="font-semibold text-slate-300">
          <p className="font-semibold text-slate-300">
            Log in to give some praise!
          </p>
        </div>

        <Form
          method="POST"
          {...form.props}
          className="rounded-2xl bg-gray-200 p-6 w-96"
        >
          <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full">
            {lastSubmission?.error.loginError}
          </div>
          <InputField
            {...conform.input(email, { type: "email", ariaAttributes: true })}
            label="Email"
            error={email.error}
            errorId={email.errorId}
          />
          <InputField
            {...conform.input(password, {
              type: "password",
              ariaAttributes: true,
            })}
            label="Password"
            error={password.error}
            errorId={password.errorId}
          />

          <div className="w-full text-center">
            <Button
              disabled={
                navigation.state === "submitting" ||
                navigation.state === "loading"
              }
              type="submit"
            >
              Login
            </Button>
          </div>
        </Form>
      </div>
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

When encountering a login error, we extend the error object by adding a custom error loginError and display it on the client side. This showcases the extensibility of conform. Lets run npm run dev and test the login, check for the cookie with name "kudos-remix-drizzle" under Browser/Applications tab. Also test for our validations, try submitting an empty form, wrong info.

Step 5: Protected Routes

Have you noticed the loaders in our register and login pages. By implementing loaders in our signup and login pages, we ensure that if a user is already logged in, they will be redirected to the home page when attempting to visit these pages. Similarly, unauthenticated users will be prevented from accessing the home page directly. The requireUserLogin function in the sessions.server.ts file helps us enforce this protection. Add the following loader to the routes/home.tsx file -

export async function loader({ request }: LoaderArgs) {
  await requireUserLogin(request);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Now delete the cookie from your browser and try to visit the "/home" page. To set the homepage as the default main page of our application, we cannot directly delete the index file. Instead, we need to redirect requests from /index to our home page. We can achieve this by implementing the following loader in our _index.tsx file but first change the extension to .ts -

import type { LoaderArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

import { requireUserLogin } from "~/services/sessions.server";

export async function loader({ request }: LoaderArgs) {
  await requireUserLogin(request);
  return redirect("/home");
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Implement the logout route

To implement the logout functionality, we need to include the logout button in the user panel component on our home page. Although we won't implement the users list at this point, we will focus on the logout button. When the user clicks on the logout button, it will submit a form to the "/logout" route. To handle this form submission, we'll create a "logout.ts" file under the "routes" directory and define an action for it. Under home.tsx

export default function HomePage() {
  return (
    <Layout>
      <div className="h-full flex">
        <div className="w-1/6 bg-gray-200 flex flex-col">
          <UsersPanel users={[]} />
        </div>
      </div>
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now create a new file routes/logout.ts and paste -

import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

import { logout } from "~/services/sessions.server";

export function loader() {
  return redirect("/");
}

export function action({ request }: ActionArgs) {
  return logout(request);
}
Enter fullscreen mode Exit fullscreen mode

From the terminal run npm run dev and test all the login and signup flows.

Conclusion

In this tutorial, we accomplished several tasks:

  • Created signup and login screens
  • Implemented user queries and session management functions
  • Utilized conform for efficient form handling
  • Added route protection
  • Implemented logout functionality In the next tutorial we will work on creating kudos. All the code for this tutorial can be found here. Until next time PEACE!

Top comments (0)