DEV Community

Cover image for Next.js Authentication with NextAuth, tRPC and Prisma ORM
Francisco Mendes
Francisco Mendes

Posted on

Next.js Authentication with NextAuth, tRPC and Prisma ORM

Many applications need to know in some way who the user is and whether or not he has permission to access a specific page and that is exactly what we are going to do in today's article.

In today's article we are going to create an application in which we are going to authenticate the user, from registering new users, logging in for people who have an account and even logging out.

Introduction

There are several approaches to creating an authentication and authorization system in a web app, but it quickly narrows when it comes to SSR. However, there are several things to take into account and to facilitate our implementation we are going to use the next-auth dependency to fully manage the user session.

Next Auth offers several providers that we can use but today I'm going to focus on Credentials because there are few resources on the internet and most applications just need to log in with an email and password.

Prerequisites

Before going further, you need:

  • Node
  • NPM
  • Next.js

In addition, you are expected to have basic knowledge of these technologies.

Getting Started

With all of the above in mind, we can now start configuring our project.

Project setup

Let's scaffold next.js app and navigate into the project directory:

npx create-next-app@latest --ts auth-project
cd auth-project
Enter fullscreen mode Exit fullscreen mode

Now we are going to configure tailwind, but the focus of the application is not the design of the application but a functionality and with that we are going to use a library called daisyUI.

npm install -D tailwindcss postcss autoprefixer
npm install daisyui
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

In the file tailwind.config.js add the paths to the pages and components folders, add the daisyUI plugin and choose a theme:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx}",
    "./src/components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [require("daisyui")],
  daisyui: {
    themes: ["dracula"],
  },
};
Enter fullscreen mode Exit fullscreen mode

Now let's add the Tailwind directives to our globals.css:

/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, all our source code, including the styles, will be inside the src/ folder.

Setup Prisma

First let's install the dependencies and initialize the Prisma setup:

npm install prisma
npx prisma init
Enter fullscreen mode Exit fullscreen mode

And let's add the following schema to our schema.prisma:

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

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id        Int      @id @default(autoincrement())
  username  String   @unique
  email     String   @unique
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

With the schema defined, you can run our first migration:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

Finally we can create the prisma client:

// @/src/common/prisma.ts
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

If you followed all the steps so far, you already have the project foundation ready.

Setup tRPC

In this part of tRPC we are already going to implement some things related to authentication but before we have that conversation, let's first configure tRPC in our project:

npm install @trpc/client @trpc/server @trpc/react @trpc/next zod react-query
Enter fullscreen mode Exit fullscreen mode

With the dependencies installed we can create a folder called server/ that will contain all our code that will be executed at the backend level. And first let's create our tRPC context because in today's example we're going to have some contextual data, but for now let's just add our Prisma client:

// @/src/server/context.ts
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";

import { prisma } from "../common/prisma";

export async function createContext(ctx: trpcNext.CreateNextContextOptions) {
  const { req, res } = ctx;

  return {
    req,
    res,
    prisma,
  };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

Then we'll create a schema using the zod library that will be reused either on the frontend to validate the form, or on the backend to define the input for our mutation:

// @/src/common/validation/auth.ts
import * as z from "zod";

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(4).max(12),
});

export const signUpSchema = loginSchema.extend({
  username: z.string(),
});

export type ILogin = z.infer<typeof loginSchema>;
export type ISignUp = z.infer<typeof signUpSchema>;
Enter fullscreen mode Exit fullscreen mode

TIn the code above we already have the login schema, signup and their data types, just install the following dependency:

npm install argon2
Enter fullscreen mode Exit fullscreen mode

With our schemas defined and the dependency installed we can start working on our tRPC router which will contain only one procedure, which will be the registration of a new user (signup):

// @/src/server/router.ts
import * as trpc from "@trpc/server";
import { hash } from "argon2";

import { Context } from "./context";
import { signUpSchema } from "../common/validation/auth";

export const serverRouter = trpc.router<Context>().mutation("signup", {
  input: signUpSchema,
  resolve: async ({ input, ctx }) => {
    const { username, email, password } = input;

    const exists = await ctx.prisma.user.findFirst({
      where: { email },
    });

    if (exists) {
      throw new trpc.TRPCError({
        code: "CONFLICT",
        message: "User already exists.",
      });
    }

    const hashedPassword = await hash(password);

    const result = await ctx.prisma.user.create({
      data: { username, email, password: hashedPassword },
    });

    return {
      status: 201,
      message: "Account created successfully",
      result: result.email,
    };
  },
});

export type ServerRouter = typeof serverRouter;
Enter fullscreen mode Exit fullscreen mode

In the code above we get the username, email and password from the mutation input, then we will check if there is a user in our application with the email provided to us. If it does not exist, we will hash the password and finally create a new account.

With our tRPC context and router created we can now create our API Route:

// @/src/pages/api/trpc/[trpc].ts
import * as trpcNext from "@trpc/server/adapters/next";

import { serverRouter } from "../../../server/router";
import { createContext } from "../../../server/context";

export default trpcNext.createNextApiHandler({
  router: serverRouter,
  createContext,
});
Enter fullscreen mode Exit fullscreen mode

Now it's time to configure the _app.tsx file as follows:

// @/src/pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { withTRPC } from "@trpc/next";

import { ServerRouter } from "../server/router";

const App = ({ Component, pageProps }: AppProps) => {
  return <Component {...pageProps} />;
};

export default withTRPC<ServerRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : "http://localhost:3000/api/trpc";

    return {
      url,
      headers: {
        "x-ssr": "1",
      },
    };
  },
  ssr: true,
})(App);
Enter fullscreen mode Exit fullscreen mode

Then we will be create the tRPC hook, to which we will add the data type of our router as a generic on the createReactQueryHooks() function, so that we can make api calls:

// @/src/common/client/trpc.ts
import { createReactQueryHooks } from "@trpc/react";

import type { ServerRouter } from "../../server/router";

export const trpc = createReactQueryHooks<ServerRouter>();
Enter fullscreen mode Exit fullscreen mode

With all that has been done so far we can finally move on to the next step.

Configure Next Auth

As mentioned before, we are going to use the Credentials provider and this one has a very similar structure to the others, the only difference is that we have to take some aspects into account:

  • was made to be used with an existing system, that is, you will need to use the authorize() handler;
  • unlike other providers, the session is stateless, ie the session data must be stored in a Json Web Token and not in the database.

Now a few things in mind we can move on to the configuration of our provider options, but first let's import the necessary dependencies:

// @/src/common/auth.ts

import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The first property we will define is our provider and the authorize handler:

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "jsmith@gmail.com",
        },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials, request) => {
        // login logic goes here
      },
    }),
  ],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The authorize() handle will contain the logic needed to perform the logic in our application. So, first we'll check if the credentials are correct using the .parseAsync() method, then we'll check if the user exists using the email provided to us.

If the user exists, we will check if the password given to us is the same as the user's password in the database. If all these steps went well, we return the user data, otherwise we will return null. Like this:

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "jsmith@gmail.com",
        },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials, request) => {
        const creds = await loginSchema.parseAsync(credentials);

        const user = await prisma.user.findFirst({
          where: { email: creds.email },
        });

        if (!user) {
          return null;
        }

        const isValidPassword = await verify(user.password, creds.password);

        if (!isValidPassword) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          username: user.username,
        };
      },
    }),
  ],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

With our provider configured, now we need to define another property, which will be the callbacks. The first callback we are going to define is jwt() which will be invoked whenever a token is created or updated.

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
  callbacks: {
    jwt: async ({ token, user }) => {
      if (user) {
        token.id = user.id;
        token.email = user.email;
      }

      return token;
    },
    // ...
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The last handler we will need in the callbacks property is the session() which is invoked whenever a session is checked and it only returns some data from the JWT.

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
  callbacks: {
    jwt: async ({ token, user }) => {
      if (user) {
        token.id = user.id;
        token.email = user.email;
      }

      return token;
    },
    session: async ({ session, token }) => {
      if (token) {
        session.id = token.id;
      }

      return session;
    },
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Last but not least we have to add two more properties related to the JWT configuration (like secret and max age) and the custom pages that we want for signin and signup.

// @/src/common/auth.ts
import { NextAuthOptions } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "argon2";

import { prisma } from "./prisma";
import { loginSchema } from "./validation/auth";

export const nextAuthOptions: NextAuthOptions = {
  // ...
  jwt: {
    secret: "super-secret",
    maxAge: 15 * 24 * 30 * 60, // 15 days
  },
  pages: {
    signIn: "/",
    newUser: "/sign-up",
  },
};
Enter fullscreen mode Exit fullscreen mode

Now we just need to create our API Route for NextAuth:

// @/src/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";

import { nextAuthOptions } from "../../../common/auth";

export default NextAuth(nextAuthOptions);
Enter fullscreen mode Exit fullscreen mode

We already have our authentication system finished, but now we need to create a HOF (High Order Function) to protect some of our routes. We are going to define whether the user has access to a route or not according to the session data and I took a lot of inspiration from this next.js docs page.

The idea of this HOF is to reuse the authorization logic on all other pages and we can always use getServerSideProps() anyway and if the user tries to access a protected page without having a session, he will be redirected to the login page.

// @/src/common/requireAuth.ts
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import { unstable_getServerSession } from "next-auth";

import { nextAuthOptions } from "./auth";

export const requireAuth =
  (func: GetServerSideProps) => async (ctx: GetServerSidePropsContext) => {
    const session = await unstable_getServerSession(
      ctx.req,
      ctx.res,
      nextAuthOptions
    );

    if (!session) {
      return {
        redirect: {
          destination: "/", // login path
          permanent: false,
        },
      };
    }

    return await func(ctx);
  };
Enter fullscreen mode Exit fullscreen mode

Now in our backend, going back to the tRPC context, we can have a similar approach in which we get the data from the session and add it to our context so that we can access the user's session data in any procedure on our router.

// @/src/server/context.ts
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import { unstable_getServerSession } from "next-auth"; // 👈 added this

import { prisma } from "../common/prisma";
import { nextAuthOptions } from "../common/auth"; // 👈 added this

export async function createContext(ctx: trpcNext.CreateNextContextOptions) {
  const { req, res } = ctx;
  const session = await unstable_getServerSession(req, res, nextAuthOptions); // 👈 added this

  return {
    req,
    res,
    session, // 👈 added this
    prisma,
  };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

And now to finish configuring our authentication system we have to go back to our _app.tsx and add the SessionProvider to the <App /> component:

// @/src/pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { SessionProvider } from "next-auth/react"; // 👈 added this
import { withTRPC } from "@trpc/next";

import { ServerRouter } from "../server/router";

// made changes to this component 👇
const App = ({ Component, pageProps }: AppProps) => {
  return (
    <SessionProvider session={pageProps.session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
};

export default withTRPC<ServerRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : "http://localhost:3000/api/trpc";

    return {
      url,
      headers: {
        "x-ssr": "1",
      },
    };
  },
  ssr: true,
})(App);
Enter fullscreen mode Exit fullscreen mode

Now, we can finally move on to creating our frontend and focus on our UI.

Create the Frontend

Now we've done a lot of things that can finally be used in our frontend but our application still doesn't have users and for that same reason we're going to start by creating the new users registration page.

For that we will need to install some more dependencies to validate the forms of our application and for that we will use the React Hook Form:

npm install react-hook-form @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

And this way, the signup page will look like the following:

// @/src/pages/sign-up.tsx
import type { NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { useCallback } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { signUpSchema, ISignUp } from "../common/validation/auth";
import { trpc } from "../common/client/trpc";

const SignUp: NextPage = () => {
  const router = useRouter();
  const { register, handleSubmit } = useForm<ISignUp>({
    resolver: zodResolver(signUpSchema),
  });

  const { mutateAsync } = trpc.useMutation(["signup"]);

  const onSubmit = useCallback(
    async (data: ISignUp) => {
      const result = await mutateAsync(data);
      if (result.status === 201) {
        router.push("/");
      }
    },
    [mutateAsync, router]
  );

  return (
    <div>
      <Head>
        <title>Next App - Register</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <form
          className="flex items-center justify-center h-screen w-full"
          onSubmit={handleSubmit(onSubmit)}
        >
          <div className="card w-96 bg-base-100 shadow-xl">
            <div className="card-body">
              <h2 className="card-title">Create an account!</h2>
              <input
                type="text"
                placeholder="Type your username..."
                className="input input-bordered w-full max-w-xs my-2"
                {...register("username")}
              />
              <input
                type="email"
                placeholder="Type your email..."
                className="input input-bordered w-full max-w-xs"
                {...register("email")}
              />
              <input
                type="password"
                placeholder="Type your password..."
                className="input input-bordered w-full max-w-xs my-2"
                {...register("password")}
              />
              <div className="card-actions items-center justify-between">
                <Link href="/" className="link">
                  Go to login
                </Link>
                <button className="btn btn-secondary" type="submit">
                  Sign Up
                </button>
              </div>
            </div>
          </div>
        </form>
      </main>
    </div>
  );
};

export default SignUp;
Enter fullscreen mode Exit fullscreen mode

As you may have noticed in the code above, we have three inputs (username, email, password) and each one corresponds to a property of our login schema.

At this point, you must have noticed that the react hook form is using zodResolver() to validate our form and as soon as it is valid, the user is created in our database and redirected to the login page. Now that we can add new users to our application, we can finally use some of Next Auth's features.

On the login page, unlike the signup page, we are not going to use our tRPC client but the signIn() function of Next Auth itself, to which we only have to define that we are going to start the session using our "credentials" provider (we also have to pass the credentials provided by the user and the callback url).

// @/src/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { useCallback } from "react";
import { signIn } from "next-auth/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { loginSchema, ILogin } from "../common/validation/auth";

const Home: NextPage = () => {
  const { register, handleSubmit } = useForm<ILogin>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = useCallback(async (data: ILogin) => {
    await signIn("credentials", { ...data, callbackUrl: "/dashboard" });
  }, []);

  return (
    <div>
      <Head>
        <title>Next App - Login</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <form
          className="flex items-center justify-center h-screen w-full"
          onSubmit={handleSubmit(onSubmit)}
        >
          <div className="card w-96 bg-base-100 shadow-xl">
            <div className="card-body">
              <h2 className="card-title">Welcome back!</h2>
              <input
                type="email"
                placeholder="Type your email..."
                className="input input-bordered w-full max-w-xs mt-2"
                {...register("email")}
              />
              <input
                type="password"
                placeholder="Type your password..."
                className="input input-bordered w-full max-w-xs my-2"
                {...register("password")}
              />
              <div className="card-actions items-center justify-between">
                <Link href="/sign-up" className="link">
                  Go to sign up
                </Link>
                <button className="btn btn-secondary" type="submit">
                  Login
                </button>
              </div>
            </div>
          </div>
        </form>
      </main>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

With our signup and login page created, we can now create the dashboard page that will be a protected route (by using the requireAuth() HOF), in this article I will show the user session data on the page and we will use the signOut() function for the user be able to log out. The page might look something like this:

// @/src/pages/dashboard/index.tsx
import type { NextPage } from "next";
import { useSession, signOut } from "next-auth/react";

import { requireAuth } from "../../common/requireAuth";

export const getServerSideProps = requireAuth(async (ctx) => {
  return { props: {} };
});

const Dashboard: NextPage = () => {
  const { data } = useSession();

  return (
    <div className="hero min-h-screen bg-base-200">
      <div className="hero-content">
        <div className="max-w-lg">
          <h1 className="text-5xl text-center font-bold leading-snug text-gray-400">
            You are logged in!
          </h1>
          <p className="my-4 text-center leading-loose">
            You are allowed to visit this page because you have a session,
            otherwise you would be redirected to the login page.
          </p>
          <div className="my-4 bg-gray-700 rounded-lg p-4">
            <pre>
              <code>{JSON.stringify(data, null, 2)}</code>
            </pre>
          </div>
          <div className="text-center">
            <button
              className="btn btn-secondary"
              onClick={() => signOut({ callbackUrl: "/" })}
            >
              Logout
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

Conclusion

As always, I hope you enjoyed this article and that it was useful to you. If you have seen any errors in the article, please let me know in the comments so that I can correct them.

Before I finish, I will share with you this link to the github repository with the project code for this article.

See you next time!

Top comments (6)

Collapse
 
gthinh profile image
Thinh Nguyen • Edited

Awesome article, exactly what I wanted to do with Prisma and Next Auth! Might need to add this property to the authOptions according to this to get sessions to work.

//pages/api/auth/[...nextauth].ts
export const authOptions: NextAuthOptions = {
  //...auth options
  session: {
    strategy: "jwt",
  }
}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
harshmangalam profile image
Harsh Mangalam

Very helpful article and learn something awesome.
Now secret property inside jwt is deprecated instead define secret in top-level.

export const nextAuthOptions: NextAuthOptions = {
 providers:[],
 callbacks:{},
 jwt:{},
 secret:'your secret here from env'
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
flowerf profile image
Flower-F

Awsome! It really helps me a lot!

Collapse
 
mrvicthor profile image
mrvicthor

thanks Francisco. Your posts have been very helpful with an app i am buiding using nextJs, typescript, trpc and prisma.

Collapse
 
aeolian profile image
klinsc

+1

Collapse
 
crunchwrap89 profile image
Marcus N

Hi,
This seems out of date already, not able to use. Could you update this with newer version so?