DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Seven things I would keep from our Next.js middleware

Next.js middleware runs on every request that matches its matcher. That makes it the most tempting place in the codebase to put things, and the most expensive place to get them wrong, because a mistake there is a mistake on every page at once.

Our middleware does four jobs: decide whether a route needs auth, refresh the Supabase session, enforce a two-device limit, and publish the visitor's country for price localisation. Here is what I would keep from how it ended up.

Public by default, and the comment that says so

The first thing in the handler is the auth model, written out in prose:

// Auth model: PUBLIC BY DEFAULT.
//
// Only the prefixes below require a signed-in user. Everything else — all
// marketing/content pages, games, pricing, auth, api, etc. — is treated as
// public at the middleware level, so adding a NEW public page needs no change
// here.
//
// This is a perf/UX gate, not the only line of defence: every page under these
// prefixes also self-guards via getUser()/redirect('/login'), and API routes
// enforce their own auth.
const PROTECTED_PREFIXES = ['/dashboard', '/exercises']
Enter fullscreen mode Exit fullscreen mode

Two things worth pulling out.

The direction of the default is a real decision. Deny by default is the correct instinct for a security boundary. It is the wrong instinct here, because this middleware is not the security boundary. Ours is a content-heavy product: around a hundred public marketing pages, provider guides, employer guides, a blog. Under deny-by-default, every new public page needs a middleware edit, and the failure mode is "our new landing page redirects Googlebot to /login", discovered a week later in search console.

The failure mode of public-by-default is that a new protected page is not gated by middleware. Which brings us to the second thing.

It says out loud that it is not the only line of defence. Every page under those prefixes calls getUser() and redirects for itself, and every API route enforces its own auth. The middleware is a fast path that saves a Supabase round trip and gives a clean redirect, not the thing standing between a stranger and your data.

If your middleware is your only auth check, one wrong character in a matcher regex is a full breach. If it is a convenience layer over per-route checks, it is a bug. Write down which one you built, in the file, because the next person cannot tell by reading it.

The early return that keeps the common path cheap

if (!isProtectedRoute) {
  // Public route — render for everyone, signed-in or not. No Supabase
  // round-trip and no device checks.
  return setCountryCookie(NextResponse.next(), request)
}
Enter fullscreen mode Exit fullscreen mode

The overwhelming majority of our traffic is public pages, and this branch means none of it pays for session refresh or device bookkeeping. Everything below this line is on the authenticated path only.

Middleware runs on the request path of every page view. A hundred milliseconds of session work on a marketing page is a hundred milliseconds on your largest contentful paint, on the pages where that matters most commercially.

Setting a cookie so a static page can read it

This is my favourite thing in the file, because it solves a genuine tension.

/**
 * Publishes the visitor's country as a cookie so prices can be localised in the
 * first paint.
 *
 * Deliberately NOT httpOnly: the pre-paint script has to read it. It carries a
 * two-letter country code and nothing else — no identifier, no session material
 * — so there is nothing here worth protecting from the page's own JavaScript.
 * Only ever written from the Vercel-set header, never from anything the client
 * sends.
 */
function setCountryCookie(response: NextResponse, request: NextRequest): NextResponse {
  const country = request.headers.get('x-vercel-ip-country')
  if (!country) return response

  response.cookies.set(COUNTRY_COOKIE, country.slice(0, 2).toUpperCase(), {
    ...LONG_LIVED_COOKIE_OPTIONS,
    httpOnly: false,
    maxAge: 60 * 60 * 24 * 30,
  })

  return response
}
Enter fullscreen mode Exit fullscreen mode

Three decisions in fifteen lines:

Not httpOnly, and the comment defends it. Any deviation from a security default should carry its justification next to it, phrased so a reviewer can check the reasoning rather than the intent. "No identifier, no session material" is checkable. "It is fine" is not.

Only ever written from the platform header. The value never comes from anything the client controls. A country cookie the client can set is a country cookie the client will set.

Re-set on every request, not only when missing. A visitor who travels stops seeing the currency of the country they left. Thirty days rather than the year we use for device tokens, because this is a display preference and a stale one is refreshed by the next visit anyway.

Await the write before you write the cookie that names it

This is the bug I would most like other people to avoid.

// Resolve this device to a slot. Awaited deliberately: the cookie below must
// only be written once a row exists for the token it names. The previous
// fire-and-forget write could be dropped when the instance was frozen after
// the response, leaving a device holding a token the database had never seen
// and, once its siblings held both slots, permanently walled out.
const slot = await ensureDeviceSlot(user.id, candidateToken, deviceFingerprint, userAgent)
Enter fullscreen mode Exit fullscreen mode

The original code did not await the insert, because why make the user wait for bookkeeping. On a serverless platform the instance can be frozen once the response is sent, so the insert sometimes never completed. The response had already set a cookie naming that token.

Result: a device carrying a token that does not exist in the database. Harmless on its own. Fatal once the account's other devices filled both slots, because the phantom token can never be matched and the real device is refused forever.

The pairing rule: if a response hands the client a reference to a record, the write creating that record must have committed before you send the response. Fire-and-forget is fine for logs and analytics. It is never fine for something the client is about to hold onto.

The same reasoning governs the refusal path:

if (!slot.allowed) {
  // No device cookie is written on refusal — an unregistered token in a
  // cookie is exactly the state that used to strand devices.
  return NextResponse.redirect(new URL('/device-limit', request.url))
}
Enter fullscreen mode Exit fullscreen mode

Turning down a request and also giving it a token you did not register recreates the bug from the other end.

The dead code, and the shouty comment that replaced it

There used to be an escape hatch letting an over-limit device still reach /device-limit and /auth/*. It turned out to be unreachable: both of those return at the public branch long before this code, so they are already exempt from the device check entirely, which is a stronger exemption than the one the escape hatch granted.

So it was deleted, and this went in its place:

// IF YOU ADD '/device-limit' OR '/auth' TO PROTECTED_PREFIXES, PUT THIS BACK.
// Without it, an over-limit device asking for /device-limit would be redirected
// to /device-limit forever, and an OAuth callback would be walled out before it
// could ever register a device.
Enter fullscreen mode Exit fullscreen mode

Dead code deleted, invariant kept. The code was dead given a configuration one line above it, and that configuration is exactly the kind of thing someone changes without reading two hundred lines down. A redirect loop on your device-limit page is not a subtle degradation.

When you delete something because it is unreachable, ask what makes it unreachable, and leave that condition written down where the person who would change it will read it.

Errors fall through to the session response

} catch (error) {
  console.error('Middleware error:', error)
  if (process.env.NODE_ENV === 'production') {
    import('@sentry/nextjs').then(({ captureException }) => { /* ... */ }).catch(() => {})
  }
  return supabaseResponse
}
Enter fullscreen mode Exit fullscreen mode

A throwing middleware takes down every page. So the catch returns the Supabase response that was already computed: the session stays correct, device enforcement is skipped for that request, the user keeps browsing. Degrading a friction mechanism is the right thing to lose when the alternative is a site-wide 500.

Note the dynamic Sentry import so it is not loaded in development, and the .catch(() => {}) so a failure to report an error cannot itself become the error.

The matcher is part of the design

matcher: [
  '/((?!_next/static|_next/image|favicon.ico|game-questions|ingest|opengraph-image|twitter-image|icon|apple-icon|.*\\.(?:svg|png|jpg|jpeg|gif|webp|json|ico|woff|woff2|ttf|eot)$).*)',
]
Enter fullscreen mode Exit fullscreen mode

Every entry is a thing you do not want to pay middleware for: static assets, images, the static question JSON, the analytics reverse proxy, Next's generated metadata routes.

The metadata routes are the ones people miss. opengraph-image and friends are real requests, they are fetched by crawlers and link unfurlers, and running session logic on them is pure waste.

See the output

The country cookie work is visible from outside. Open cogniprep.app/pricing and look at the price and the footnote under it, which states the exact conversion rate used. Then check your cookies for cp_country: a two-letter code, readable by the page, and nothing else.

Turn on a VPN, reload, and both the cookie and the price change together in the first paint. That is the whole point of doing this in middleware rather than after hydration.

Summary

  • Choose the default direction of your auth check based on what your app mostly is, and write down whether the middleware is a boundary or a fast path.
  • Return early for the common case so public traffic pays nothing.
  • Never fire and forget a write whose identifier you are about to hand to the client.
  • Justify every security default you deviate from, in a way a reviewer can verify.
  • When you delete unreachable code, record the condition that makes it unreachable.
  • Catch and degrade, because a throwing middleware is a site-wide outage.
  • Treat the matcher as part of the design, not boilerplate.

Top comments (0)