Building a full stack app with Remix & Drizzle ORM: Register & Login users
Tags: react, remix, drizzle, typescript
Published: 2023-06-28 16:42:03

### 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](https://conform.guide/), a form library that simplifies working with Remix forms, streamlining the development experience.

Credit for inspiring this tutorial series goes [Sabin Adams](https://twitter.com/sabinthedev), whose insightful [tutorial series](https://www.prisma.io/blog/fullstack-remix-prisma-mongodb-1-7D0BfTXBmB6r) 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](https://github.com/yaldram/kudos-remix-drizzle/tree/feat/user-signups)

### 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 -
```ts
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 });
};
```
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 -
```ts
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!");
};
```
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.
```bash
npm install bcryptjs
npm install --save-dev @types/bcryptjs
```
Under the `services/users.server.ts` file add these queries -
```ts
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();
}
```
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 - 
```ts
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),
    },
  });
}
```
- 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 - 
```bash
npm install @conform-to/react @conform-to/zod zod
```
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 -
```tsx
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>
  );
}
```
- 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` -
```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>
  );
}
```
You might have noticed that after signup we navigate the user to "/home" page, lets create it `app/routes/home.tsx` - 
```tsx
import { Layout } from "~/layouts/Layout";

export default function HomePage() {
  return <Layout>Home Page</Layout>;
}
```
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 - 
```tsx
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>
  );
}
```
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 - 
```ts
export async function loader({ request }: LoaderArgs) {
  await requireUserLogin(request);

  return null;
}
```
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` - 
```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");
}
```

### 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`
```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>
  );
}
```
Now create a new file `routes/logout.ts` and paste - 
```ts
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);
}
```
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](https://github.com/yaldram/kudos-remix-drizzle/tree/feat/user-signups). Until next time PEACE!