Skip to main content

next.js app router

Route handler wrapper, server component checks, permission map generation, and edge middleware for Next.js.

Install

import {
  withAccess,
  checkAccess,
  getPermissions,
  createNextMiddleware,
} from '@gentleduck/iam/server/next'
import {
  withAccess,
  checkAccess,
  getPermissions,
  createNextMiddleware,
} from '@gentleduck/iam/server/next'

Covers four protection layers in Next.js App Router: route handler wrappers, Server Component / Server Action checks, permission map generation for client hydration, and edge middleware.


Route handler wrapper

Wrap App Router route handlers (GET, POST, DELETE, etc.).

import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
 
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  await deletePost(id)
  return Response.json({ deleted: true })
}
 
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
  getUserId: async (req) => {
    const session = await auth()
    return session?.user?.id ?? null
  },
})
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
 
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  await deletePost(id)
  return Response.json({ deleted: true })
}
 
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
  getUserId: async (req) => {
    const session = await auth()
    return session?.user?.id ?? null
  },
})

The wrapper automatically reads params.id as the resource instance ID. params may be a Promise (Next.js 15+) or an object.


Server component checks

Check a permission inside a React Server Component or Server Action:

import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
 
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
  const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
 
  return (
    <article>
      <h1>Post {id}</h1>
      {canEdit && <EditButton />}
      {canDelete && <DeleteButton />}
    </article>
  )
}
import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
 
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
  const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
 
  return (
    <article>
      <h1>Post {id}</h1>
      {canEdit && <EditButton />}
      {canDelete && <DeleteButton />}
    </article>
  )
}

checkAccess is a thin wrapper around engine.can() with positional args matching common Next.js patterns (subjectId, action, resourceType, resourceId?, scope?).


Permission map generation

Generate a PermissionMap on the server and pass it to the client for hydration:

import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()
  const userId = session?.user?.id
 
  const permissions = userId
    ? await getPermissions(engine, userId, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}
 
  return (
    <html>
      <body>
        <AccessProvider permissions={permissions}>{children}</AccessProvider>
      </body>
    </html>
  )
}
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()
  const userId = session?.user?.id
 
  const permissions = userId
    ? await getPermissions(engine, userId, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}
 
  return (
    <html>
      <body>
        <AccessProvider permissions={permissions}>{children}</AccessProvider>
      </body>
    </html>
  )
}

See client/react for the consuming side.


Edge middleware

Protect routes at the edge before they reach application code:

// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
 
const checkAccess = createNextMiddleware(engine, {
  getUserId: async (req) => {
    const token = req.headers.get('authorization')?.replace('Bearer ', '')
    return token ? decodeUserId(token) : null
  },
  rules: [
    { pattern: '/api/admin', resource: 'admin', action: 'manage' },
    { pattern: '/api/posts', resource: 'post' }, // action inferred from HTTP method
    { pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
  ],
})
 
export async function middleware(req: Request) {
  const denied = await checkAccess(req)
  if (denied) return denied // 401 or 403 Response
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/api/:path*'],
}
// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
 
const checkAccess = createNextMiddleware(engine, {
  getUserId: async (req) => {
    const token = req.headers.get('authorization')?.replace('Bearer ', '')
    return token ? decodeUserId(token) : null
  },
  rules: [
    { pattern: '/api/admin', resource: 'admin', action: 'manage' },
    { pattern: '/api/posts', resource: 'post' }, // action inferred from HTTP method
    { pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
  ],
})
 
export async function middleware(req: Request) {
  const denied = await checkAccess(req)
  if (denied) return denied // 401 or 403 Response
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/api/:path*'],
}

When no rule matches a request path, the middleware returns null and the request passes through.

Rule patterns

  • String pattern — simple prefix match (/api/posts matches /api/posts, /api/posts/42, /api/posts/foo/bar)
  • RegExp pattern — full regex test against pathname (use for stricter matching, optional segments, glob-like behavior)

Comparing the four entry points

HelperWhere it runsGranularity
createNextMiddlewareEdge (before handler)Route-shape gating
withAccessServer (route handler)Route-shape + resource ID
checkAccessServer (RSC, Server Action)Single check, full control
getPermissionsServer (Layout, RSC)Batch generation for hydration

createNextMiddleware() is the cheapest — it runs at the edge and never touches the database adapter unless you wire one in. withAccess() runs in the function context with full subject resolution. checkAccess() and getPermissions() are explicit calls inside server logic.


Options reference

withAccess options

OptionTypeDefaultDescription
getUserId(req) -> string or null or Promisex-user-id headerExtract the subject ID
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
scopestringundefinedFixed scope for this handler
onError(err, req) -> Response500 JSONCustom error handler

createNextMiddleware options

OptionTypeDefaultDescription
rulesArray<Rule>--Route rules. Required.
getUserId(req) -> string or null or Promise--Extract the subject ID. Required.
onError(err, req) -> Response500 JSONCustom error handler

Rule format

FieldTypeRequiredDescription
patternstring or RegExpyesString prefix match or regex pattern
resourcestringyesResource type for matched routes
actionstringnoFixed action (defaults to HTTP method map)
scopestringnoRequired scope for matched routes