Permix

Next.js

Learn how to use Permix with the Next.js App Router

Overview

Permix works well with the Next.js App Router when you split responsibilities clearly:

  • Use permix/next on the server to create request-scoped instances and dehydrated snapshots.
  • Use permix/react in client components for hydration and reactive UI checks.
  • Revalidate cached permission snapshots explicitly after role changes.

Do not mutate one shared Permix server singleton across requests. In the App Router that can leak permission state between overlapping requests.

The local reference implementation for this guide lives in the Next repro app at examples/next-app-router.

For onboarding, start with next-minimal. For a broader multi-role application, use next-blog-cms. For reproductions and framework handler edge cases, use next-app-router.

Security Boundaries

These layers are not equivalent:

  • API routes and route handlers are authoritative.
  • Server components, pages, and layouts are the right place for hard gating with notFound() or redirects.
  • Client checks are for UI only.

If the action must be secure, enforce it on the server even if the client also hides the UI.

Server Snapshot

Create the effective permission rules on the server, then turn them into a dehydrated snapshot for the client:

app/cases/page.tsx
import { createPermixSnapshot } from '@aminzoubaa/permix/next'
import { getMergedRoleRules } from '@/lib/permissions'
import { getSessionRoles } from '@/lib/auth'
import { PermissionsClient } from './permissions-client'

export default async function Page() {
  const roles = await getSessionRoles()
  const { state } = createPermixSnapshot(getMergedRoleRules(roles))

  return <PermissionsClient state={state} />
}

This keeps the server authoritative while still letting the client render immediately from a serialized snapshot.

Server Layout Guards

When an entire route segment should disappear for unauthorized users, guard it in the server layout:

app/(protected)/layout.tsx
import { notFound } from 'next/navigation'
import { createServerPermix } from '@aminzoubaa/permix/next'
import { getMergedRoleRules } from '@/lib/permissions'
import { getCurrentRoles } from '@/lib/session'

export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
  const roles = await getCurrentRoles()
  const permix = createServerPermix(getMergedRoleRules(roles))

  if (!permix.check('reports', 'read')) {
    notFound()
  }

  return <>{children}</>
}

This keeps back/forward navigation honest because the server remains the source of truth.

Client Hydration

On the client, continue using the React integration:

app/cases/permissions-client.tsx
'use client'

import type { Permix, PermixDefinition, PermixStateJSON } from '@aminzoubaa/permix'
import { createPermix } from '@aminzoubaa/permix'
import { PermixHydrate, PermixProvider, usePermix } from '@aminzoubaa/permix/react'
import { useState } from 'react'

type AppDefinition = PermixDefinition<{
  post: {
    action: 'create' | 'read'
  }
}>

function Content({ permix }: { permix: Permix<AppDefinition> }) {
  const { check, isReady } = usePermix(permix)

  if (!isReady) {
    return <div>Loading permissions...</div>
  }

  return check('post', 'create') ? <button>Create</button> : null
}

export function PermissionsClient({ state }: { state: PermixStateJSON<AppDefinition> }) {
  const [permix] = useState(() => createPermix<AppDefinition>())

  return (
    <PermixProvider permix={permix}>
      <PermixHydrate state={state}>
        <Content permix={permix} />
      </PermixHydrate>
    </PermixProvider>
  )
}

If your server rules are fully boolean, hydration is enough to make the client ready immediately. If any rule was function-based, it is dehydrated to null and you should call setup() on the client to restore it.

In Next.js, do not keep a mutable createPermix() singleton at module scope inside the client tree. Create it lazily with useState or useRef inside the client boundary so server renders and browser navigations do not share stale permission state.

Cache Invalidation

If you cache permission snapshots in the App Router, revalidate them when roles change:

lib/server-permix.ts
import { createCachedPermixSnapshot, revalidatePermixTags } from '@aminzoubaa/permix/next'
import { getMergedRoleRules } from './permissions'
import { getCurrentRoles } from './session'

export const getCachedSnapshot = createCachedPermixSnapshot(
  async (permissionVersion: string) => getMergedRoleRules(await getCurrentRoles()),
  {
    keyParts: ['permix-snapshot'],
    tags: ['permix'],
  },
)

export function invalidatePermissions() {
  revalidatePermixTags('permix')
}

Then call invalidatePermissions() after settings changes, a role update, or logout.

Also pass a monotonic permission version or ETag into the cached loader. That changes the cache key when permissions change and avoids relying on tag revalidation alone:

const snapshot = await getCachedSnapshot(session.permissionVersion)

If the page itself must reflect runtime permission changes, render it dynamically. Otherwise Next.js can serve pre-rendered HTML even when the underlying permission snapshot tag was revalidated.

Event-Based Updates

You do not need to re-fetch user context in every component. Once the client instance exists, you can invalidate it and rehydrate or set it up again when something meaningful changes:

permix.invalidate()

permix.hook('invalidate', () => {
  console.log('Permissions are no longer trusted')
})

Typical triggers:

  • logout
  • session refresh
  • server-sent “roles changed” events
  • switching organizations or teams

This pattern is usually a better fit than repeatedly re-reading user context inside every component.

Grouped Checks

If a route or component should be available when any of several permission conditions matches, use grouped checks instead of manually creating many intermediate booleans:

const canOpenDashboard = permix.checkSome(
  { entity: 'reports', action: 'read' },
  { entity: 'billing', action: 'refund' },
  { entity: 'post', action: 'create' },
)

const canRunDangerousFlow = permix.checkEvery(
  { entity: 'post', action: 'read' },
  { entity: 'post', action: 'delete' },
)

These grouped checks are cheap. They are just a small number of synchronous in-memory permission evaluations, so using a handful of them in a route or component is not a practical runtime concern.

Performance Notes

  • Prefer one request-scoped server snapshot per request instead of many nested server setup() calls.
  • Let the server decide hard access and send the client a ready snapshot instead of recomputing the same rules deep in the tree.
  • Use checkSome and checkEvery for readability; they are just thin in-memory wrappers around normal checks.
  • If a page depends on permission changes at runtime, pair cache invalidation with router.refresh() so the server tree is recomputed.

Hono in Route Handlers

You can run Hono inside the App Router by using app.fetch(request):

app/api/hono/[[...route]]/route.ts
import { Hono } from 'hono'
import { createPermix } from '@aminzoubaa/permix/hono'
import { getMergedRoleRules } from '@/lib/permissions'
import { getDemoConfig } from '@/lib/store'

const app = new Hono().basePath('/api/hono')
const permix = createPermix<Definition>()

app.use('*', permix.setupMiddleware(() => {
  return getMergedRoleRules(getDemoConfig().roles)
}))

app.get('/reports', permix.checkMiddleware('reports', 'read'), (c) => {
  return c.json({ ok: true })
})

export const GET = (request: Request) => app.fetch(request)

Permix now lets genuine route errors bubble through Hono instead of incorrectly rewriting them as forbidden responses.

tRPC in Route Handlers

For tRPC route handlers, use the fetch adapter and attach a request-scoped Permix context in your procedure middleware:

app/api/trpc/[trpc]/route.ts
import { initTRPC } from '@trpc/server'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { createPermix } from '@aminzoubaa/permix/trpc'
import { getMergedRoleRules } from '@/lib/permissions'
import { getDemoConfig } from '@/lib/store'

const t = initTRPC.context<{ permix?: ReturnType<typeof permix.setup> }>().create()
const permix = createPermix<Definition>()

const protectedProcedure = t.procedure.use(({ next }) => {
  const config = getDemoConfig()

  return next({
    ctx: {
      permix: permix.setup(getMergedRoleRules(config.roles)),
    },
  })
})

const router = t.router({
  reportsRead: protectedProcedure
    .use(permix.checkMiddleware('reports', 'read'))
    .query(() => ({ ok: true })),
})

const handler = (request: Request) => fetchRequestHandler({
  router,
  endpoint: '/api/trpc',
  req: request,
  createContext: () => ({}),
})

export { handler as GET, handler as POST }

Troubleshooting

I changed roles but the UI still shows the old result

  • Revalidate the snapshot tag on the server.
  • Call router.refresh() if the current page depends on server data.
  • If the client instance should drop trust immediately, call permix.invalidate().

I get “Rules wasn't provided”

  • You are checking before setup() or hydrate() completed.
  • In client components, gate on isReady.
  • In server components, always create the snapshot before rendering the client boundary.

Hono or tRPC says the Permix instance is missing

  • Ensure the setup middleware or protected procedure runs before any permission check.
  • In Hono, setupMiddleware() must execute on the matching route tree.
  • In tRPC, ctx.permix must be attached before checkMiddleware() runs.

My function permissions work on the server but fail on the client after hydration

  • That is expected unless you restore them with setup() on the client.
  • Only boolean snapshots are fully serializable.

My page keeps re-rendering or feels slow after I added Permix

  • Avoid a mutable module-scope singleton in the client tree.
  • Create the client instance once per mounted client boundary with useState or useRef.
  • Push hard permission decisions to the server and hydrate the client with the result.