Fullstack Authentication with Remix using Prisma, MongoDB and Typescript

isnan__h

ishan

Posted on May 16, 2022

Fullstack Authentication with Remix using Prisma, MongoDB and Typescript

Remix is an edge-first server-side rendered JavaScript framework built on React that allows us to build full-stack web applications thanks to its frontend and server-side capabilities. With the motto "Web Fundamentals, Modern UX" as its APIs follow as much as possible the web standards like: HTTP responses, form submissions, inbuilt loader for data fetching and many exciting features baked in.

In the recent 2021 'Javascript Rising Stars' Remix was ranked among the top full-stack framework of choice among developers. Remix got a lot of traction (and $3M in seed funding, which helps too!) and it was open sourced. But, Remix is not a new framework as previously it was available as a subscription-based premium framework.

What are we building

We will make the use of Remix alongside with MongoDB as our database with Prisma ORM using Typescript and build a fully working authentication application from scratch. For this we will make the use of 'Built-in Support for Cookies' feature provided as a built-in function called createCookie to work with cookies.

Prerequisites

  • Node.js 14+ (this uses v16.14.0)
  • npm 7+
  • A code editor

Creating the project

We will first initialize a new Remix project with the command

npx create-remix@latest
Enter fullscreen mode Exit fullscreen mode

We will give a name to our project and call it

remix-mongo-auth
Enter fullscreen mode Exit fullscreen mode

We also want to start with just the basic starter template and proceed with the rest of installation process. We have also used Tailwind to spice up our application the starter files can be found in the repository here.

Connecting our database

For our database we are using MongoDB which is a non-relational document based database. For our ease we will configure it using Mongo Atlas and grab the connection string from there to later configure our app.

Please note you may need to activate the admin rights your user in operating some tasks later. It can be done under database access settings.

Configuring PrismaORM

We will start with installing the Prisma dev dependency in order to interact with the MongoDB and push the database changes.

npm i -D prisma
Enter fullscreen mode Exit fullscreen mode

This will install the Prisma CLI for us. We then want to initialize prisma using the MongoDB (default to Postgres) with the command

npx prisma init --datasource-provider mongodb
Enter fullscreen mode Exit fullscreen mode

We must now see a prisma folder created into our directory and inside that will be our schema.prisma file created for us. Inside the file we will write prisma schema launguage where we will create models needed to do the authentication implementation.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  email     String   @unique
  password  String
  profile   Profile
}

type Profile {
  fullName String
}
Enter fullscreen mode Exit fullscreen mode

Here we created a user model and profile model. A user will have its reference to the Profile document.

The id column is a string which is an auto generated values provided by Mongo. @db.ObjectId is to give any unique id to the database. DateTime @default(now()) is the current timestamp we have provided to createdAt. Rest columns is just a data type we provide to the data structure.

In order to see and reflect the changes inside our database we need to add a new file which will be responsible in connecting our database and Remix application.

//utils/prisma.server.ts
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
declare global {
  var __db: PrismaClient | undefined;
}

if (process.env.NODE_ENV === "production") {
  prisma = new PrismaClient();
  prisma.$connect();
} else {
  if (!global.__db) {
    global.__db = new PrismaClient();
    global.__db.$connect(); 
  }
  prisma = global.__db;
}

export * from "@prisma/client";
export { prisma };

Enter fullscreen mode Exit fullscreen mode

The above snippet is taken from Remix document where it will instantiate new PrismaClient if no existing connection client to DB is found.

Now, we can run the command to apply schema changes.

npx prisma db push   
Enter fullscreen mode Exit fullscreen mode

This will create any new collection and indexes defined in our schema. We can now check if our changes are all working. We can run command

npx prisma studio      
Enter fullscreen mode Exit fullscreen mode

This will spin up a default port, where we can see the reflection of changes with the columns that is created to us. Which will look something as below

Image description

Adding a Layouts

We want our application to have a standard layout in which we can wrap all the application inside it. This comes in handy if we will create multiple Layouts in multiple pages, passing a children prop.

export function Layout({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Registering Users

Let's start adding the registration for new users. We will need to install some libraries before we begin. We will need a library lets install it

npm i bcrypt
Enter fullscreen mode Exit fullscreen mode

This library will help us in hashing our password before we save it in our database. As we really dont want to act fool saving plain text passwords in our DB. To learn more about hashing useing bcrypt please refer this article here.

Creating type interface

As we are using typescript we will first begin by creating the type interface for our Registration data types needed. Below is the type we created

//utils/types.server.ts
export type RegisterForm = {
  email: string;
  password: string;
  fullName?: string;
};
Enter fullscreen mode Exit fullscreen mode

We will now create a function which will take in the user object which contains our email, password and full name and turn that password to the hashed password, finally creates a new user in our MongoDB.

Note: Adding .server extension will let Remix know it is the server file which wont get bundled in our client-side javascript file. So sensitive information wont get exposed to clients.

//utils/user.server.ts
import bcrypt from "bcryptjs";
import type { RegisterForm } from "./types.server";
import { prisma } from "./prisma.server";

export const createUser = async (user: RegisterForm) => {
  const passwordHash = await bcrypt.hash(user.password, 10);
  const newUser = await prisma.user.create({
    data: {
      email: user.email,
      password: passwordHash,
      profile: {
        fullName: user.fullName,
      },
    },
  });
  return { id: newUser.id, email: user.email };
}; 
Enter fullscreen mode Exit fullscreen mode

We will now make the use of Cookie feature provided by Remix. Which helps us to generate new cookie session.

//utils/auth.server.ts
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),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Until this point we have created our createCookieSessionStorage function which will create a new cookie session. Lets create this function

//utils/auth.server.ts

const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) throw new Error("Secret not specified, it must be set");

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

Enter fullscreen mode Exit fullscreen mode

Now we have everything needed to write our registerUser function. Which will check the user exist in database with a unique email. If there is unique email we will create a new user session if not we will send a JSON response with something went wrong.

//utils/auth.server.ts
export async function registerUser(form: RegisterForm) {
  const userExists = await prisma.user.count({ where: { email: form.email } });
  if (userExists) {
    return json(
      { error: `User already exists with that email` },
      { status: 400 }
    );
  }

  const newUser = await createUser(form);
  if (!newUser) {
    return json(
      {
        error: `Something went wrong trying to create a new user.`,
        fields: { email: form.email, password: form.password, fullName: form.fullName },
      },
      { status: 400 }
    );
  }
  return createUserSession(newUser.id, "/");
}
Enter fullscreen mode Exit fullscreen mode
//utils/auth.server.ts

export async function getUser(request: Request) {
  const userId = await getUserId(request);
  if (typeof userId !== "string") {
    return null;
  }

  try {
    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, email: true, profile: true },
    });
    return user;
  } catch {
    throw logout(request);
  }
}

function getUserSession(request: Request) {
  return storage.getSession(request.headers.get("Cookie"));
}

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(`/auth/login?${searchParams.toString()}`);
  }
  return userId;
}
Enter fullscreen mode Exit fullscreen mode

We will create one additional function which will return us the user info of the user that has been created to us.

//utils/user.server.ts
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 prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, email: true, profile: true },
    });
    return user;
  } catch {
    throw logout(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

After everything needed to create a new user function is written. We will create couple of new files inside our routes folder.

useLoaderData is a custom hook provided by remix to load the data into components. Remix also provides loader function which we will be called in the server before rendering the page.

//routes/index.ts
import { LoaderFunction, redirect } from '@remix-run/node';
import { requireUserId } from '~/utils/auth.server';

export const loader: LoaderFunction = async ({ request }) => {
  await requireUserId(request);
  return redirect('/home');
};
Enter fullscreen mode Exit fullscreen mode

Inside our main index.ts file we will check is we do have the user id available to us if it results to true we will redirect to /home route.

//routes/auth/register.tsx
import { useState } from 'react';
import { Layout } from '~/layout/layout';
import { Link, useActionData } from '@remix-run/react';
import { ActionFunction, LoaderFunction, redirect } from '@remix-run/node';
import { registerUser, getUser } from '~/utils/auth.server';

export const loader: LoaderFunction = async ({ request }) => {
  // If user has active session, redirect to the homepage
  return (await getUser(request)) ? redirect('/') : null;
};

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const email = form.get('email');
  const password = form.get('password');
  const fullName = form.get('fullName');

  if (!email || !password || !fullName) {
    return {
      status: 400,
      body: 'Please provide email and password',
    };
  }

  if (
    typeof email !== 'string' ||
    typeof password !== 'string' ||
    typeof fullName !== 'string'
  ) {
    throw new Error(`Form not submitted correctly.`);
  }

  const allFields = { email, password, fullName };
  const user = await registerUser(allFields);
  return user;
};

export default function Register() {
  const actionData = useActionData();
  const [formError, setFormError] = useState(actionData?.error || '');

  return (
    <>
      <Layout>
        <div className="min-h-full flex items-center justify-center mt-[30vh]">
          <div className="max-w-md w-full space-y-8">
            <div>
              <span className="text-center text-slate-400 block">
                Welcome fellas!
              </span>
              <h2 className="text-center text-3xl font-extrabold text-gray-900">
                Register your account
              </h2>
            </div>

            <form method="post">
              <div>
                <div>
                  <label htmlFor="email-address" className="sr-only">
                    Full name
                  </label>
                  <input
                    id="user-name"
                    name="fullName"
                    type="text"
                    autoComplete="name"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Full name"
                    defaultValue={actionData?.fullName}
                  />
                </div>
                <div>
                  <label htmlFor="email-address" className="sr-only">
                    Email address
                  </label>
                  <input
                    id="email-address"
                    name="email"
                    type="email"
                    autoComplete="email"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Email address"
                    defaultValue={actionData?.email}
                  />
                </div>
                <div>
                  <label htmlFor="password" className="sr-only">
                    Password
                  </label>
                  <input
                    id="password"
                    name="password"
                    type="password"
                    autoComplete="current-password"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Password"
                    defaultValue={actionData?.password}
                  />
                </div>
              </div>
              <button
                type="submit"
                className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mt-5"
              >
                Register account
              </button>
              <div>
                <p className="text-sm text-center mt-5">
                  Already have an account?
                  <span className="underline pl-1 text-green-500">
                    <Link to="/auth/login">Login</Link>
                  </span>
                </p>
              </div>
              <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full">
                {formError}
              </div>
            </form>
          </div>
        </div>
      </Layout>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Login Users

Let's also create a function which will login new users into our application.

export async function loginUser({ email, password }: LoginForm) {
  const user = await prisma.user.findUnique({
    where: { email },
  });

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return json({ error: `Incorrect login` }, { status: 400 });
  }

  //redirect to homepage if user created
  return createUserSession(user.id, '/');
}

Enter fullscreen mode Exit fullscreen mode

This function will query our database and look for the email we have passed in as a parameter is there is no email and password don't match we redirect to main route.

Adding Routing

It's time we now can create all the route needed in our overall application. We will create couple of routes so that we can add some protected route and redirect when we don't have cookie set. Routing inside Remix works the same like they would working with Next or Nuxt(SSR) applications.

Register route

//routes/auth/register.tsx
import { useState } from 'react';
import { Layout } from '~/layout/layout';
import { Link, useActionData } from '@remix-run/react';
import { ActionFunction, LoaderFunction, redirect } from '@remix-run/node';
import { registerUser, getUser } from '~/utils/auth.server';

export const loader: LoaderFunction = async ({ request }) => {
  // If user has active session, redirect to the homepage
  return (await getUser(request)) ? redirect('/') : null;
};

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const email = form.get('email');
  const password = form.get('password');
  const fullName = form.get('fullName');

  if (!email || !password || !fullName) {
    return {
      status: 400,
      body: 'Please provide email and password',
    };
  }

  if (
    typeof email !== 'string' ||
    typeof password !== 'string' ||
    typeof fullName !== 'string'
  ) {
    throw new Error(`Form not submitted correctly.`);
  }

  const allFields = { email, password, fullName };
  const user = await registerUser(allFields);
  return user;
};

export default function Register() {
  const actionData = useActionData();
  const [formError, setFormError] = useState(actionData?.error || '');

  return (
    <>
      <Layout>
        <div className="min-h-full flex items-center justify-center mt-[30vh]">
          <div className="max-w-md w-full space-y-8">
            <div>
              <span className="text-center text-slate-400 block">
                Welcome fellas!
              </span>
              <h2 className="text-center text-3xl font-extrabold text-gray-900">
                Register your account
              </h2>
            </div>

            <form method="post">
              <div>
                <div>
                  <label htmlFor="email-address" className="sr-only">
                    Full name
                  </label>
                  <input
                    id="user-name"
                    name="fullName"
                    type="text"
                    autoComplete="name"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Full name"
                    defaultValue={actionData?.fullName}
                  />
                </div>
                <div>
                  <label htmlFor="email-address" className="sr-only">
                    Email address
                  </label>
                  <input
                    id="email-address"
                    name="email"
                    type="email"
                    autoComplete="email"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Email address"
                    defaultValue={actionData?.email}
                  />
                </div>
                <div>
                  <label htmlFor="password" className="sr-only">
                    Password
                  </label>
                  <input
                    id="password"
                    name="password"
                    type="password"
                    autoComplete="current-password"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Password"
                    defaultValue={actionData?.password}
                  />
                </div>
              </div>
              <button
                type="submit"
                className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mt-5"
              >
                Register account
              </button>
              <div>
                <p className="text-sm text-center mt-5">
                  Already have an account?
                  <span className="underline pl-1 text-green-500">
                    <Link to="/auth/login">Login</Link>
                  </span>
                </p>
              </div>
              <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full">
                {formError}
              </div>
            </form>
          </div>
        </div>
      </Layout>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Login route

import { useState } from 'react';
import { Layout } from '~/layout/layout';
import { useActionData, Link } from '@remix-run/react';
import { ActionFunction, LoaderFunction, redirect } from '@remix-run/node';
import { loginUser, getUser } from '~/utils/auth.server';

export const loader: LoaderFunction = async ({ request }) => {
  // If user has active session, redirect to the homepage
  return (await getUser(request)) ? redirect('/') : null;
};

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const email = form.get('email')?.toString();
  const password = form.get('password')?.toString();

  if (!email || !password)
    return {
      status: 400,
      body: 'Please provide email and password',
    };

  const user = await loginUser({ email, password });
  return user;
};

export default function Login() {
  const actionData = useActionData();
  const [formError, setFormError] = useState(actionData?.error || '');

  return (
    <>
      <Layout>
        <div className="min-h-full flex items-center justify-center mt-[30vh]">
          <div className="max-w-md w-full space-y-8">
            <div>
              <span className="text-center text-slate-400 block">
                Welcome back!
              </span>
              <h2 className="text-center text-3xl font-extrabold text-gray-900">
                Log in to your account
              </h2>
            </div>
            <form className="mt-8 space-y-6" action="#" method="POST">
              <input type="hidden" name="remember" value="true" />
              <div className="rounded-md shadow-sm -space-y-px">
                <div>
                  <label htmlFor="email-address" className="sr-only">
                    Email address
                  </label>
                  <input
                    id="email-address"
                    name="email"
                    type="email"
                    autoComplete="email"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Email address"
                    defaultValue={actionData?.email}
                  />
                </div>
                <div>
                  <label htmlFor="password" className="sr-only">
                    Password
                  </label>
                  <input
                    id="password"
                    name="password"
                    type="password"
                    autoComplete="current-password"
                    required
                    className="appearance-none rounded-none relative block w-full px-3 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
                    placeholder="Password"
                    defaultValue={actionData?.password}
                  />
                </div>
              </div>

              <div>
                <button
                  type="submit"
                  className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                >
                  Log in
                </button>
              </div>
              <div>
                <p className="text-sm text-center">
                  I dont have an account?
                  <span className="underline pl-1 text-green-500">
                    <Link to="/auth/register">Register</Link>
                  </span>
                </p>
              </div>
              <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full">
                {formError}
              </div>
            </form>
          </div>
        </div>
      </Layout>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Util this point we are ready to test our our implementation of session storage for our users. This should work as expected creating a new session for logged in users and also new session for fresh registered user.

Logged in page

We will create a logged in page where users can see their currently logged in username and email with a warm welcome message.

//routes/home.tsx
import {
  ActionFunction,
  LoaderFunction,
  redirect,
  json,
} from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getUser } from '~/utils/auth.server';
import { logout } from '~/utils/auth.server';
import { Layout } from '~/layout/layout';

export const loader: LoaderFunction = async ({ request }) => {
  // If user has active session, redirect to the homepage
  const userSession = await getUser(request);
  if (userSession === null || undefined) return redirect('/auth/login');
  return json({ userSession });
};

export const action: ActionFunction = async ({ request }) => {
  return logout(request);
};

export default function Index() {
  const { userSession } = useLoaderData();
  const userName = userSession?.profile.fullName;
  const userEmail = userSession?.email;

  return (
    <>
      <Layout>
        <div className="text-center m-[30vh] block">
          <div>
            <small className="text-slate-400 pb-5 block">You are Logged!</small>
            <h1 className="text-4xl text-green-600 font-bold pb-3">
              Welcome to Remix Application
            </h1>
            <p className="text-slate-400">
              Name: {userName}, Email: {userEmail}
            </p>
          </div>
          <div className="text-sm mt-[40px]">
            <form action="/auth/logout" method="POST">
              <button
                name="_action"
                value="delete"
                className="font-medium text-red-600 hover:text-red-500"
              >
                Log me out
              </button>
            </form>
          </div>
        </div>
      </Layout>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Logout Users

//routes/auth/logout.tsx
export async function logout(request: Request) {
  const session = await getUserSession(request); 
  return redirect("/auth/logout", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

We have made the use of storage.destroy method Remix has provided us to remove the session stored into our browsers. We also need to create a dedicated file which will redirect us to that route and remove session stored.

//route/auth/logout.tsx
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/utils/auth.server";

export const action: ActionFunction = async ({ request }) => logout(request);
export const loader: LoaderFunction = async () => redirect("/");

Enter fullscreen mode Exit fullscreen mode

Conclusion

We have successfully created our authentication with Remix, MongoDB, Prisma, Tailwind with Typescript. Although Remix is a fresh still growing framework there are lots of advantages we have over other existing similar frameworks. Due to this it have become one of the loved framework to work on in modern development.

Sites with lots of dynamic content would benefit from Remix as it is Ideal for applications involving databases, dynamic data, user accounts with private data, etc. There are still so much more we can implement with the powerful features provided to us. We just scratched surface, you can learn more about remix in their official documentation here.

Please find the source code for this article in github link here.

Happy coding!

💖 💪 🙅 🚩
isnan__h
ishan

Posted on May 16, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related