AuthIntermediate~1–2 hours

Add authentication

Almost every app eventually needs to know who the user is — to gate pages, scope data to the right account, and bill people. Authentication is the identity layer that everything else (authorization, multi-tenancy, payments) is built on, and it's the one part of your stack you should never hand-roll.

the approach

In 2026 the right move is to hand identity to a managed provider and spend your time wiring it into your app — not reinventing password hashing, session rotation, and OAuth flows. Clerk is the fastest drop-in for React/Next.js; Better Auth is the pick when you want to own your data in a TypeScript codebase; Supabase Auth is natural if you're already on Supabase. Whichever you choose, treat the provider as the single source of truth for identity, and enforce every access decision on the server. The client is just UI — assume it can be bypassed.

Step by step

  1. 1

    Pick a provider — don't build it

    Use Clerk, Better Auth, or Supabase Auth. Rolling your own means owning password hashing, session expiry and rotation, email verification, OAuth, and breach response forever. A managed provider gives you all of that plus social logins and MFA out of the box.

  2. 2

    Install, add keys, wrap the app

    Install the SDK (e.g. `npm i @clerk/nextjs`), put the publishable key in a NEXT_PUBLIC_ var and the secret key in a server-only var, and wrap your root layout in the provider component. Never let the secret key touch a NEXT_PUBLIC_ name — that ships it to the browser.

  3. 3

    Protect routes with middleware

    Add a `middleware.ts` that matches your private routes and calls `auth.protect()`. This is your first gate: signed-out users get bounced to the sign-in page before the protected page ever renders. See the snippet.

  4. 4

    Drop in sign-in / sign-up UI

    Use the provider's prebuilt components (`<SignIn />`, `<SignUp />`, `<UserButton />`) instead of hand-building forms. They handle validation, error states, social buttons, and MFA for you. Restyle later — ship working auth first.

  5. 5

    Enforce auth on the server and at the data layer

    This is the step people skip. Every server action, API route, and database query that touches user data must re-check the session (`const { userId } = await auth()`) and scope the query to that user. Hiding a button is not security — assume any endpoint can be called directly.

  6. 6

    Sync users to your database via webhooks

    If your app needs its own user rows (for foreign keys, roles, or a Stripe customer ID), set up a webhook so the provider notifies your backend on user.created/updated. Verify the webhook signature, and handle the race where a user signs in before the webhook lands.

  7. 7

    Configure production before you deploy

    Set production keys and add your deployed domain to the provider's allowed redirect/callback URLs. The classic failure is auth that works on localhost and 500s in production because the live callback URL was never whitelisted.

Starter snippet

typescript
// middleware.ts — Clerk v6 + Next.js 15 App Router
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

// Everything under these paths requires a signed-in user
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/settings(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect() // redirects to sign-in if not authenticated
  }
})

export const config = {
  matcher: [
    // Skip Next.js internals and static files
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|png|svg|woff2?)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
}

// In a Server Component or route handler, gate data like this:
//   import { auth } from '@clerk/nextjs/server'
//   const { userId } = await auth()
//   if (!userId) return new Response('Unauthorized', { status: 401 })
//   const rows = await db.query.notes.findMany({ where: eq(notes.userId, userId) })

✕ Watch out for

  • Protecting only the UI. Hiding a dashboard link doesn't stop anyone from calling the API route directly — enforce auth on every server handler and query, not just in the browser.
  • Leaking the secret key. Anything in a NEXT_PUBLIC_ (or otherwise client-exposed) variable ships to the browser. Signing keys and API secrets must stay server-only.
  • Rolling your own sessions or password hashing. This is where real breaches come from — expiry, rotation, timing attacks, and reset flows are all easy to get subtly and dangerously wrong.
  • Forgetting production callback URLs. Auth that works on localhost breaks on deploy because the live domain isn't in the provider's allowed redirect list.
  • Treating middleware as the only gate. Middleware can be bypassed or misconfigured — always re-read the session server-side in the handler and scope every query to the authenticated userId.

✓ Pro tips

  • Tell your AI agent the exact provider AND framework version: 'Clerk v6 with Next.js 15 App Router using clerkMiddleware.' Left vague, agents love to emit outdated NextAuth v4 code that no longer matches the current docs.
  • Explicitly instruct the agent to enforce auth at the data layer, then make it show you every protected query and where it checks userId. Agents happily lock down routes and quietly forget the database.
  • After it's built, test the signed-out path directly: `curl` a protected API route with no session cookie and confirm you get a 401 or redirect, not data. The browser can lie to you; curl won't.
  • Grep your built client bundle for the actual value of your secret key to confirm it never shipped to the browser.