Protecting Pages in Next.js Apps with Middleware

Protecting Pages in Next.js Apps with Middleware

TLDR: Implementing middleware for authentication or other purposes in a Next.js app involves understanding its execution context, placement within the project structure, and how to effectively use matchers and conditional logic to control access to routes. Through the lessons below, I've shared insights that can help streamline the process of securing pages in your Next.js applications.

Content

When developing a Next.js application, ensuring that certain pages are accessible only to authenticated users is a common requirement. This blog post will share insights and challenges from my experience setting up middleware for enforcing authentication in a Next.js app, specifically using Supabase Auth with the Next.js App Router.

Understanding Middleware in Next.js

Middleware in the Next.js context is essentially code that runs before a request is completed. It allows you to modify the response based on the request, whether that's through rewriting, redirecting, modifying headers, or directly responding.

Key Lessons Learned

Lesson 1: File Placement Matters

The location of your middleware.ts file within your project structure is crucial. For effective functioning, it should be placed at the root level, aligning with the structure of your app. In my case, that meant at the same level as the app folder. Like this:

├── src
│   ├── app
│   ├── middleware.ts

Lesson 2: Utilizing Matchers

Matchers define the paths on which the middleware should run, which for us was all paths except static files, image optimization files, and auth callback routes. They can be as simple as a single path or encompass multiple paths in an array. For instance:

// Single path, should run on /home
export const config = {
  matcher: '/home',
}

OR

// Multiple paths, should run on /home and /about
export const config = {
  matcher: ['/home', '/about'],
}

The matcher can be exact paths or regex patterns, enabling a broad or specific match as needed.

It can also include parameters eg. /home/:path matches /home/a and /home/b.

Further, /about/:path* would match on the same routes as /about/(.*). Essentially, any past that starts with /about would be a match. For example /about/a/b/c.

💡 The matcher values need to be constants so they can be statically analyzed at build-time. Dynamic values such as variables will be ignored.

Lesson 3: Conditional Statements in Middleware

Middleware can include conditional statements to modify requests or responses. These can also be used to set which routes the middleware should run on. We used conditional statements to enforce authentication on pages.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {

  // gets the signed in user
  const {
    data: { user },
  } = await supabase.auth.getUser()

  ...

  // if user is signed in and the current path is /login 
  // redirect the user to /home
  if (user && req.nextUrl.pathname === '/login') {
    return NextResponse.redirect(new URL('/home', req.url))
  }

  // if user is not signed in and the current path is not /login
  // redirect the user to /login
  if (!user && req.nextUrl.pathname !== '/login') {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  // More conditions and matcher can be added here...
}

The first condition will only run on the /login route and redirect the signed in user to /home.

The second condition ensures that if a person who is not signed in lands on a path that is not /login, they are redirected to our login page at /login

Lesson 4: Middleware Execution Order

Understanding the order in which middleware and other routing configurations are executed is fundamental. The process follows a specific sequence from headers and redirects in next.config.js, through middleware functions, to filesystem and dynamic routes, and finally fallback rewrites.

Nextjs version used: 14.0.3

More info in the official docs: Routing: Middleware | Next.js