Published from Publish Studio
In the previous article "Implementing Cookie-Based JWT Authentication in a tRPC Backend", I shared how to secure the backend built with tRPC. Now it's time to secure the front end built with Next.js.
Adding authentication to Next.js is a piece of cake. Thanks to Next.js middleware, it's easier than ever to protect endpoints.
This is part 4 of the "Building a Full-Stack App with tRPC and Next.js" series. I recommended reading the first 3 parts if you haven't to better understand this part.
Let's learn how to secure our Finance Tracker (GitHub repo) frontend.
Enable Credentials
If you remember from the previous article, we are using cookie-based authentication. So whenever we log in, auth tokens are sent in response headers and the browser automatically stores them in cookies.
But before we do that, we have to enable credentials in the tRPC client, so that browser knows it has to send cookies in network requests.
Add fetch method and set credentials: "include"
// src/lib/providers/trpc.tsx
const trpcClient = trpc.createClient({
transformer: superjson,
links: [
loggerLink({
enabled: () => process.env.NODE_ENV === "development",
}),
httpBatchLink({
url: process.env.NEXT_PUBLIC_TRPC_API_URL,
fetch: (url, options) => { // <--- add fetch method
return fetch(url, {
...options,
credentials: "include", // <--- set this
});
},
}),
],
});
Now cookies will be sent in all requests.
Similarly, do this in the backend too.
// src/index.ts
app.use(
cors({
origin: "http://localhost:3000", // we need to specify origins in order for this to work, wildcard "*" doesn't work
credentials: true, // <--- here
})
);
Now backend will allow requests containing cookies.
Create Login and Register Pages
Implement register
Since auth tokens are sent only after logging in, after registration, redirect the user to the login page.
This is how I usually structure my Next.js projects.
.
└── src/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ └── layout.tsx
└── components/
└── modules/
└── auth/
├── login-form.tsx
└── register-form.tsx
app/(auth)/layout.tsx
shares a common layout for login and register pages.
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<main className="flex items-center justify-center min-h-screen">
<div className="w-full max-w-md">{children}</div>
</main>
);
}
// app/(auth)/register/page.tsx
import RegisterForm from "@/components/modules/auth/register-form";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Register | Finance Tracker",
};
export default function LoginPage() {
return <RegisterForm />;
}
// app/components/modules/auth/register-form.tsx
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trpc } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export default function RegisterForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
const router = useRouter();
const { mutateAsync: register, isLoading } = trpc.auth.register.useMutation({
onSuccess: () => {
router.replace("/login");
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
try {
await register(data);
} catch (error) {
console.error(error);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Create an account</CardTitle>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="email"
disabled={isLoading}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="me@example.com"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
disabled={isLoading}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="********"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
Create account
</Button>
<p>
Already have an account? <Link href="/login">Login</Link>
</p>
</CardFooter>
</form>
</Form>
</Card>
);
}
Implement login
Let's create a login page and test if our setup is working or not.
Create a login route.
// (auth)/login/page.tsx
import LoginForm from "@/components/modules/auth/login-form";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Login | Finance Tracker",
};
export default function LoginPage() {
return <LoginForm />;
}
Finally, add a login form.
// src/components/modules/auth/login-form.tsx
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trpc } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export default function LoginForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
const router = useRouter();
const { mutateAsync: login, isLoading } = trpc.auth.login.useMutation({
onSuccess: () => {
router.push("/");
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
try {
await login(data);
} catch (error) {
console.error(error);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Login to your account</CardTitle>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="email"
disabled={isLoading}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="me@example.com"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
disabled={isLoading}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="********"
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
Login
</Button>
<p>
Don't have an account? <Link href="/register">Register</Link>
</p>
</CardFooter>
</form>
</Form>
</Card>
);
}
Let's test this. Start backend and frontend. Navigate to http://localhost:3000/login
. Try logging in with your email and password.
You will be redirected to the dashboard. Now, open browser console -> Application tab -> Cookies -> http://localhost:3000
, you can see the stored cookies.
Protect Endpoints with Next.js Middleware
We almost had everything we needed.
Now, try to open /login
and /register
pages while being already logged in or dashboard (/
) page while being not logged in, you are able to do so. But we shouldn't let that happen. Also, if you wait some time, accessToken
and logged_in
cookies disappear because they expire after 15 minutes. We have to fix this too.
Both of these problems can be solved by Next.js middleware.
Using this middleware, let's check if a user is logged in before showing a page. Accordingly, redirect the user to the appropriate page.
// src/middleware.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export default async function middleware(request: NextRequest) {
const response = NextResponse.next();
const pathname = request.nextUrl.pathname;
const loggedIn = request.cookies.get("logged_in");
const authUrls = new Set(["/login", "/register"]);
if (loggedIn && authUrls.has(pathname)) {
return NextResponse.redirect(new URL("/", request.url));
}
return response;
}
// Don't run middleware for these files
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
Here, we are checking if logged_in
cookie is present. If it is present and the user is trying to access auth endpoints, the user is redirected to the dashboard page.
Now let's implement a refreshing access token so that users don't have to log in every 15 minutes.
Before we move on, I've to tell you one thing. Nextjs middleware is a server component means the tRPC client created with createTRPCReact
doesn't work. To make tRPC work server-side, tRPC provides createTRPCProxyClient
.
Here's the tRPC server client:
// src/utils/trpc.ts
export const createTRPCServerClient = (headers: HTTPHeaders) =>
createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
loggerLink({
enabled: () => process.env.NODE_ENV === "development",
}),
httpBatchLink({
url: process.env.NEXT_PUBLIC_TRPC_API_URL!,
headers() {
return headers;
},
fetch: (url, options) => {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
});
Since this middleware runs in edge runtime (e.g. in Vercel server if hosted with Vercel) but not in the browser, cookies will not sent in request and also cannot be sent in response. So we have to modify refreshAccessToken
method in the backend to send access tokens in the response body instead of headers. Then we will set cookies in the middleware ourselves.
First, change refreshAccessToken
procedure from protectedProcedure
to publicProcedure
since accessToken
will be empty, and the server can't verify the request.
// backend/src/modules/auth/auth.routes.ts
refreshAccessToken: publicProcedure.mutation(({ ctx }) => // <--- here
new AuthController().refreshAccessTokenHandler(ctx)
),
Now, modify refreshAccessTokenHandler
to send accessToken
in the response body.
// backend/src/modules/auth/auth.controller.ts
async refreshAccessTokenHandler(ctx: Context) { // <--- Context change
const cookies = new Cookies(ctx.req, ctx.res, {
secure: process.env.NODE_ENV === "production",
});
const refreshToken = cookies.get("refreshToken");
if (!refreshToken) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Refresh token is required",
});
}
const accessToken = await super.refreshAccessToken(refreshToken);
return { success: true, accessToken }; // <--- here
}
Switch to frontend and implement refreshing access token inside middleware:
async function refreshAccessToken(
request: NextRequest,
response: NextResponse,
refreshToken: string
) {
try {
const client = createTRPCServerClient({
Cookie: `refreshToken=${refreshToken}`,
});
const { accessToken } = await client.auth.refreshAccessToken.mutate();
response.cookies.set("accessToken", accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
response.cookies.set("logged_in", "true", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
Use it in middleware:
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createTRPCServerClient } from "./utils/trpc";
/**
* Middleware function to handle authentication and redirection based on user login status and URL path.
*
* @param {NextRequest} request - The incoming request object.
* @returns {Promise<NextResponse>} - The response object after processing the middleware logic.
*
* This middleware performs the following checks:
* 1. If the user is logged in and trying to access authentication-related pages (login or register),
* it redirects them to the root URL ("/").
* 2. If the user does not have a valid refresh token and is trying to access a page that requires authentication,
* it redirects them to the login page ("/login").
* 3. If the user is not logged in but has a refresh token and is trying to access a page that requires authentication,
* it attempts to refresh the access token.
*/
export default async function middleware(request: NextRequest) {
const response = NextResponse.next();
const pathname = request.nextUrl.pathname;
const loggedIn = request.cookies.get("logged_in");
const refreshToken = request.cookies.get("refreshToken")?.value;
const authUrls = new Set(["/login", "/register"]);
if (loggedIn && authUrls.has(pathname)) {
return NextResponse.redirect(new URL("/", request.url));
}
if (!refreshToken && !authUrls.has(pathname)) {
return NextResponse.redirect(new URL("/login", request.url));
}
if (!loggedIn && refreshToken && !authUrls.has(pathname)) {
await refreshAccessToken(request, response, refreshToken);
}
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
async function refreshAccessToken(
request: NextRequest,
response: NextResponse,
refreshToken: string
) {
try {
const client = createTRPCServerClient({
Cookie: `refreshToken=${refreshToken}`,
});
const { accessToken } = await client.auth.refreshAccessToken.mutate();
response.cookies.set("accessToken", accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
response.cookies.set("logged_in", "true", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
I've added comments to the code to make things clearer.
That's it! It's that easy.
Let me know if you have any doubts.
Follow for more amazing content in this series 🎸.
Top comments (0)