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
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
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"],
},
};
Now let's add the Tailwind directives to our globals.css:
/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
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
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
}
With the schema defined, you can run our first migration:
npx prisma migrate dev --name init
Finally we can create the prisma client:
// @/src/common/prisma.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
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
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>;
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>;
TIn the code above we already have the login schema, signup and their data types, just install the following dependency:
npm install argon2
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;
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,
});
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);
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>();
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 = {
// ...
};
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
},
}),
],
// ...
};
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,
};
},
}),
],
// ...
};
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;
},
// ...
},
// ...
};
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;
},
},
// ...
};
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",
},
};
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);
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);
};
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>;
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);
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
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;
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;
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;
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)
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.Very helpful article and learn something awesome.
Now
secret
property insidejwt
is deprecated instead definesecret
in top-level.Awsome! It really helps me a lot!
thanks Francisco. Your posts have been very helpful with an app i am buiding using nextJs, typescript, trpc and prisma.
+1
Hi,
This seems out of date already, not able to use. Could you update this with newer version so?