Skip to main content

Next.js

WIP

Writing the auth routes as App Router route handlers over auth.flows, reading the session in server components, and guarding your own routes

A route handler composes no middleware, so one wrapper gives every route the CSRF guard and the error mapping:

lib/auth.ts
import { drizzlePgAdapter } from '@gentleduck/auth/adapters/drizzle/pg'
import { createAuth } from '@gentleduck/auth/core'
import { errorResponse } from '@gentleduck/auth/server/generic'
import { withNextCsrf } from '@gentleduck/auth/server/next'

export const auth = createAuth({
  baseUrl: 'https://app.example.com',
  stores: drizzlePgAdapter(process.env.DATABASE_URL ?? ''),
  // ...
})

export function route<Args extends [Request, ...unknown[]]>(
  handler: (...args: Args) => Promise<Response>,
): (...args: Args) => Promise<Response> {
  return withNextCsrf(auth, async (...args) => {
    try {
      return await handler(...args)
    } catch (err) {
      return errorResponse(err)
    }
  })
}
app/api/auth/signin/route.ts
import { AuthError } from '@gentleduck/auth/core'
import { executeIntents, readBodyJson } from '@gentleduck/auth/server/generic'
import { nextCaller } from '@gentleduck/auth/server/next'
import { auth, route } from '@/lib/auth'

export const POST = route(async (req) => {
  const body = await readBodyJson(req)
  if (typeof body !== 'object' || body === null) throw new AuthError('AUTH_INVALID_PARAMETERS')
  const providerId: unknown = Reflect.get(body, 'providerId')
  if (typeof providerId !== 'string') throw new AuthError('AUTH_INVALID_PARAMETERS')
  const { intents } = await auth.flows.signIn({
    input: Reflect.get(body, 'input') ?? {},
    providerId,
    ...nextCaller(req),
    previousSid: auth.transport.extract(req) ?? undefined,
  })
  return executeIntents(intents)
})
app/api/auth/signout/route.ts
import { executeIntents } from '@gentleduck/auth/server/generic'
import { auth, route } from '@/lib/auth'

export const POST = route(async (req) => {
  const sid = auth.transport.extract(req)
  return executeIntents(sid ? (await auth.flows.signOut(sid)).intents : auth.transport.revoke())
})
app/api/auth/session/route.ts
import { jsonResponse } from '@gentleduck/auth/server/generic'
import { auth, route } from '@/lib/auth'

export const GET = route(async (req) => {
  const resolved = await auth.resolveSession(req).orNull()
  if (!resolved) return jsonResponse(200, { session: null, identity: null })
  // `csrfHash` is server-side state; the browser holds the plaintext
  const { csrfHash: _csrfHash, ...session } = resolved.session
  return jsonResponse(200, { session, identity: resolved.identity })
})
  • A route handler sees no client IP, so nextCaller gives sessions only the User-Agent.
  • withNextCsrf and the wrapper pass every argument on, the route's { params } included.
  • The provider routes are on the OAuth page. The callback skips withNextCsrf, so catch its throw with errorResponse there yourself.

Reading the session

In a server component:

app/account/page.tsx
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'

export default async function AccountPage() {
  const resolved = await auth.resolveSession({ headers: await headers() }).orNull()
  if (!resolved) redirect('/sign-in')
  return <h1>Hello, {resolved.identity?.profile.email}</h1>
}

In a route handler, pass the request itself: auth.resolveSession(req).

Your own routes

app/api/profile/route.ts
import { nextWithActor } from '@gentleduck/auth/server/next'
import { auth, route } from '@/lib/auth'

export const POST = route(
  nextWithActor(auth, async (req: Request) => {
    const resolved = await auth.resolveSession(req).orNull()
    if (!resolved) return Response.json({ error: 'unauthenticated' }, { status: 401 })
    return Response.json(await updateProfile(resolved.identity, await req.json()))
  }),
)

nextWithActor answers a refusal itself, since Next turns any throw from a route into a 500.