Next.js
WIPWriting 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
nextCallergives sessions only the User-Agent. withNextCsrfand 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 witherrorResponsethere 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.