Cookie-Based Authentication in Nextjs App Router Application
Rakesh Potnuru
written by Rakesh Potnuru6 min read

Cookie-Based Authentication in Nextjs App Router Application

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"

tsx
1// src/lib/providers/trpc.tsx 2 3const trpcClient = trpc.createClient({ 4 transformer: superjson, 5 links: [ 6 loggerLink({ 7 enabled: () => process.env.NODE_ENV === "development", 8 }), 9 httpBatchLink({ 10 url: process.env.NEXT_PUBLIC_TRPC_API_URL, 11 fetch: (url, options) => { // <--- add fetch method 12 return fetch(url, { 13 ...options, 14 credentials: "include", // <--- set this 15 }); 16 }, 17 }), 18 ], 19});

Now cookies will be sent in all requests.

Similarly, do this in the backend too.

ts
1// src/index.ts 2 3app.use( 4 cors({ 5 origin: "http://localhost:3000", // we need to specify origins in order for this to work, wildcard "*" doesn't work 6 credentials: true, // <--- here 7 }) 8);

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.

shell
1. 2└── src/ 3 ├── app/ 4 │ ├── (auth)/ 5 │ │ ├── login/ 6 │ │ │ └── page.tsx 7 │ │ └── register/ 8 │ │ └── page.tsx 9 │ └── layout.tsx 10 └── components/ 11 └── modules/ 12 └── auth/ 13 ├── login-form.tsx 14 └── register-form.tsx

app/(auth)/layout.tsx shares a common layout for login and register pages.

tsx
1export default function AuthLayout({ 2 children, 3}: Readonly<{ 4 children: React.ReactNode; 5}>) { 6 return ( 7 <main className="flex items-center justify-center min-h-screen"> 8 <div className="w-full max-w-md">{children}</div> 9 </main> 10 ); 11}
tsx
1// app/(auth)/register/page.tsx 2 3import RegisterForm from "@/components/modules/auth/register-form"; 4import { Metadata } from "next"; 5 6export const metadata: Metadata = { 7 title: "Register | Finance Tracker", 8}; 9 10export default function LoginPage() { 11 return <RegisterForm />; 12}
tsx
1// app/components/modules/auth/register-form.tsx 2 3"use client"; 4 5import { Button } from "@/components/ui/button"; 6import { 7 Card, 8 CardContent, 9 CardFooter, 10 CardHeader, 11 CardTitle, 12} from "@/components/ui/card"; 13import { 14 Form, 15 FormControl, 16 FormField, 17 FormItem, 18 FormLabel, 19 FormMessage, 20} from "@/components/ui/form"; 21import { Input } from "@/components/ui/input"; 22import { trpc } from "@/utils/trpc"; 23import { zodResolver } from "@hookform/resolvers/zod"; 24import Link from "next/link"; 25import { useRouter } from "next/navigation"; 26import { useForm } from "react-hook-form"; 27import { z } from "zod"; 28 29const formSchema = z.object({ 30 email: z.string().email(), 31 password: z.string(), 32}); 33 34export default function RegisterForm() { 35 const form = useForm<z.infer<typeof formSchema>>({ 36 resolver: zodResolver(formSchema), 37 defaultValues: { 38 email: "", 39 password: "", 40 }, 41 }); 42 43 const router = useRouter(); 44 45 const { mutateAsync: register, isLoading } = trpc.auth.register.useMutation({ 46 onSuccess: () => { 47 router.replace("/login"); 48 }, 49 }); 50 51 const onSubmit = async (data: z.infer<typeof formSchema>) => { 52 try { 53 await register(data); 54 } catch (error) { 55 console.error(error); 56 } 57 }; 58 59 return ( 60 <Card> 61 <CardHeader> 62 <CardTitle>Create an account</CardTitle> 63 </CardHeader> 64 <Form {...form}> 65 <form onSubmit={form.handleSubmit(onSubmit)}> 66 <CardContent className="space-y-4"> 67 <FormField 68 control={form.control} 69 name="email" 70 disabled={isLoading} 71 render={({ field }) => ( 72 <FormItem> 73 <FormLabel>Email</FormLabel> 74 <FormControl> 75 <Input 76 type="email" 77 placeholder="me@example.com" 78 autoComplete="email" 79 {...field} 80 /> 81 </FormControl> 82 <FormMessage /> 83 </FormItem> 84 )} 85 /> 86 <FormField 87 control={form.control} 88 name="password" 89 disabled={isLoading} 90 render={({ field }) => ( 91 <FormItem> 92 <FormLabel>Password</FormLabel> 93 <FormControl> 94 <Input 95 type="password" 96 placeholder="********" 97 autoComplete="new-password" 98 {...field} 99 /> 100 </FormControl> 101 <FormMessage /> 102 </FormItem> 103 )} 104 /> 105 </CardContent> 106 <CardFooter className="flex flex-col gap-4"> 107 <Button type="submit" className="w-full" disabled={isLoading}> 108 Create account 109 </Button> 110 <p> 111 Already have an account? <Link href="/login">Login</Link> 112 </p> 113 </CardFooter> 114 </form> 115 </Form> 116 </Card> 117 ); 118}

Implement login

Let's create a login page and test if our setup is working or not.

Create a login route.

tsx
1// (auth)/login/page.tsx 2 3import LoginForm from "@/components/modules/auth/login-form"; 4import { Metadata } from "next"; 5 6export const metadata: Metadata = { 7 title: "Login | Finance Tracker", 8}; 9 10export default function LoginPage() { 11 return <LoginForm />; 12}

Finally, add a login form.

tsx
1// src/components/modules/auth/login-form.tsx 2 3"use client"; 4 5import { Button } from "@/components/ui/button"; 6import { 7 Card, 8 CardContent, 9 CardFooter, 10 CardHeader, 11 CardTitle, 12} from "@/components/ui/card"; 13import { 14 Form, 15 FormControl, 16 FormField, 17 FormItem, 18 FormLabel, 19 FormMessage, 20} from "@/components/ui/form"; 21import { Input } from "@/components/ui/input"; 22import { trpc } from "@/utils/trpc"; 23import { zodResolver } from "@hookform/resolvers/zod"; 24import Link from "next/link"; 25import { useRouter } from "next/navigation"; 26import { useForm } from "react-hook-form"; 27import { z } from "zod"; 28 29const formSchema = z.object({ 30 email: z.string().email(), 31 password: z.string(), 32}); 33 34export default function LoginForm() { 35 const form = useForm<z.infer<typeof formSchema>>({ 36 resolver: zodResolver(formSchema), 37 defaultValues: { 38 email: "", 39 password: "", 40 }, 41 }); 42 43 const router = useRouter(); 44 45 const { mutateAsync: login, isLoading } = trpc.auth.login.useMutation({ 46 onSuccess: () => { 47 router.push("/"); 48 }, 49 }); 50 51 const onSubmit = async (data: z.infer<typeof formSchema>) => { 52 try { 53 await login(data); 54 } catch (error) { 55 console.error(error); 56 } 57 }; 58 59 return ( 60 <Card> 61 <CardHeader> 62 <CardTitle>Login to your account</CardTitle> 63 </CardHeader> 64 <Form {...form}> 65 <form onSubmit={form.handleSubmit(onSubmit)}> 66 <CardContent className="space-y-4"> 67 <FormField 68 control={form.control} 69 name="email" 70 disabled={isLoading} 71 render={({ field }) => ( 72 <FormItem> 73 <FormLabel>Email</FormLabel> 74 <FormControl> 75 <Input 76 type="email" 77 placeholder="me@example.com" 78 autoComplete="email" 79 {...field} 80 /> 81 </FormControl> 82 <FormMessage /> 83 </FormItem> 84 )} 85 /> 86 <FormField 87 control={form.control} 88 name="password" 89 disabled={isLoading} 90 render={({ field }) => ( 91 <FormItem> 92 <FormLabel>Password</FormLabel> 93 <FormControl> 94 <Input 95 type="password" 96 placeholder="********" 97 autoComplete="current-password" 98 {...field} 99 /> 100 </FormControl> 101 <FormMessage /> 102 </FormItem> 103 )} 104 /> 105 </CardContent> 106 <CardFooter className="flex flex-col gap-4"> 107 <Button type="submit" className="w-full" disabled={isLoading}> 108 Login 109 </Button> 110 <p> 111 Don&apos;t have an account? <Link href="/register">Register</Link> 112 </p> 113 </CardFooter> 114 </form> 115 </Form> 116 </Card> 117 ); 118}

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.

browser 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.

ts
1// src/middleware.ts 2 3import type { NextRequest } from "next/server"; 4import { NextResponse } from "next/server"; 5 6export default async function middleware(request: NextRequest) { 7 const response = NextResponse.next(); 8 const pathname = request.nextUrl.pathname; 9 const loggedIn = request.cookies.get("logged_in"); 10 11 const authUrls = new Set(["/login", "/register"]); 12 13 if (loggedIn && authUrls.has(pathname)) { 14 return NextResponse.redirect(new URL("/", request.url)); 15 } 16 17 return response; 18} 19 20// Don't run middleware for these files 21export const config = { 22 matcher: [ 23 /* 24 * Match all request paths except for the ones starting with: 25 * - api (API routes) 26 * - _next/static (static files) 27 * - _next/image (image optimization files) 28 * - favicon.ico (favicon file) 29 */ 30 "/((?!api|_next/static|_next/image|favicon.ico).*)", 31 ], 32};

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:

ts
1// src/utils/trpc.ts 2 3export const createTRPCServerClient = (headers: HTTPHeaders) => 4 createTRPCProxyClient<AppRouter>({ 5 transformer: superjson, 6 links: [ 7 loggerLink({ 8 enabled: () => process.env.NODE_ENV === "development", 9 }), 10 httpBatchLink({ 11 url: process.env.NEXT_PUBLIC_TRPC_API_URL!, 12 headers() { 13 return headers; 14 }, 15 fetch: (url, options) => { 16 return fetch(url, { 17 ...options, 18 credentials: "include", 19 }); 20 }, 21 }), 22 ], 23 });

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.

ts
1// backend/src/modules/auth/auth.routes.ts 2 3 refreshAccessToken: publicProcedure.mutation(({ ctx }) => // <--- here 4 new AuthController().refreshAccessTokenHandler(ctx) 5 ),

Now, modify refreshAccessTokenHandler to send accessToken in the response body.

ts
1// backend/src/modules/auth/auth.controller.ts 2 3 async refreshAccessTokenHandler(ctx: Context) { // <--- Context change 4 const cookies = new Cookies(ctx.req, ctx.res, { 5 secure: process.env.NODE_ENV === "production", 6 }); 7 8 const refreshToken = cookies.get("refreshToken"); 9 if (!refreshToken) { 10 throw new TRPCError({ 11 code: "UNAUTHORIZED", 12 message: "Refresh token is required", 13 }); 14 } 15 16 const accessToken = await super.refreshAccessToken(refreshToken); 17 18 return { success: true, accessToken }; // <--- here 19 }

Switch to frontend and implement refreshing access token inside middleware:

ts
1async function refreshAccessToken( 2 request: NextRequest, 3 response: NextResponse, 4 refreshToken: string 5) { 6 try { 7 const client = createTRPCServerClient({ 8 Cookie: `refreshToken=${refreshToken}`, 9 }); 10 11 const { accessToken } = await client.auth.refreshAccessToken.mutate(); 12 13 response.cookies.set("accessToken", accessToken, { 14 httpOnly: true, 15 secure: process.env.NODE_ENV === "production", 16 sameSite: "strict", 17 path: "/", 18 }); 19 20 response.cookies.set("logged_in", "true", { 21 httpOnly: true, 22 secure: process.env.NODE_ENV === "production", 23 sameSite: "strict", 24 path: "/", 25 }); 26 27 return NextResponse.next(); 28 } catch { 29 return NextResponse.redirect(new URL("/login", request.url)); 30 } 31}

Use it in middleware:

ts
1import type { NextRequest } from "next/server"; 2import { NextResponse } from "next/server"; 3import { createTRPCServerClient } from "./utils/trpc"; 4 5/** 6 * Middleware function to handle authentication and redirection based on user login status and URL path. 7 * 8 * @param {NextRequest} request - The incoming request object. 9 * @returns {Promise<NextResponse>} - The response object after processing the middleware logic. 10 * 11 * This middleware performs the following checks: 12 * 1. If the user is logged in and trying to access authentication-related pages (login or register), 13 * it redirects them to the root URL ("/"). 14 * 2. If the user does not have a valid refresh token and is trying to access a page that requires authentication, 15 * it redirects them to the login page ("/login"). 16 * 3. If the user is not logged in but has a refresh token and is trying to access a page that requires authentication, 17 * it attempts to refresh the access token. 18 */ 19export default async function middleware(request: NextRequest) { 20 const response = NextResponse.next(); 21 const pathname = request.nextUrl.pathname; 22 const loggedIn = request.cookies.get("logged_in"); 23 const refreshToken = request.cookies.get("refreshToken")?.value; 24 25 const authUrls = new Set(["/login", "/register"]); 26 27 if (loggedIn && authUrls.has(pathname)) { 28 return NextResponse.redirect(new URL("/", request.url)); 29 } 30 31 if (!refreshToken && !authUrls.has(pathname)) { 32 return NextResponse.redirect(new URL("/login", request.url)); 33 } 34 35 if (!loggedIn && refreshToken && !authUrls.has(pathname)) { 36 await refreshAccessToken(request, response, refreshToken); 37 } 38 39 return response; 40} 41 42export const config = { 43 matcher: [ 44 /* 45 * Match all request paths except for the ones starting with: 46 * - api (API routes) 47 * - _next/static (static files) 48 * - _next/image (image optimization files) 49 * - favicon.ico (favicon file) 50 */ 51 "/((?!api|_next/static|_next/image|favicon.ico).*)", 52 ], 53}; 54 55async function refreshAccessToken( 56 request: NextRequest, 57 response: NextResponse, 58 refreshToken: string 59) { 60 try { 61 const client = createTRPCServerClient({ 62 Cookie: `refreshToken=${refreshToken}`, 63 }); 64 65 const { accessToken } = await client.auth.refreshAccessToken.mutate(); 66 67 response.cookies.set("accessToken", accessToken, { 68 httpOnly: true, 69 secure: process.env.NODE_ENV === "production", 70 sameSite: "strict", 71 path: "/", 72 }); 73 74 response.cookies.set("logged_in", "true", { 75 httpOnly: true, 76 secure: process.env.NODE_ENV === "production", 77 sameSite: "strict", 78 path: "/", 79 }); 80 81 return NextResponse.next(); 82 } catch { 83 return NextResponse.redirect(new URL("/login", request.url)); 84 } 85}

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.

Buy me a pizza 🫣👇.

Socials


Interested in
working
with me?

Let's Connect

© 2021 - 2025 itsrakesh. v2.