Fullstack Authentication with Remix using Prisma, MongoDB and Typescript
ishan
Posted on May 16, 2022
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
We will give a name to our project and call it
remix-mongo-auth
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
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
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
}
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 };
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
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
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
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>;
}
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
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;
};
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 };
};
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),
},
});
}
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,
},
});
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, "/");
}
//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;
}
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);
}
}
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');
};
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>
</>
);
}
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, '/');
}
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>
</>
);
}
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>
</>
);
}
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>
</>
);
}
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),
},
});
}
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("/");
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!
Posted on May 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.