Permix

TanStack Start

Learn how to use Permix with TanStack Start

Overview

Permix provides a dedicated integration for TanStack Start through permix/tanstack-start. It exposes a createPermix factory that returns a per-request Permix instance backed by TanStack Start's server request context.

You setupMiddleware() the rules once per request and get() them on the server inside server functions and server routes — without threading the instance through props. A second instance lives on the router context, so beforeLoad and loader can check permissions isomorphically, and the same instance backs the React integration (permix/react) via PermixProvider + PermixHydrate.

Before getting started with the TanStack Start integration, make sure you've completed the initial setup steps in the Quick Start guide. Familiarity with the Hydration guide helps too.

Define your permissions

Create a Permix instance once in a shared module so it can be imported anywhere on the server:

lib/permix.ts
import type { ValidateDefinition } from 'permix'
import { createPermix } from 'permix/tanstack-start'

interface Post {
  id: string
  authorId: string
}

// Define your permissions once and reuse this type on the server and client.
export type PermissionsDefinition = ValidateDefinition<{
  post: [
    { name: 'create', type: Post },
    { name: 'read', type: Post },
    { name: 'update', type: Post },
    { name: 'delete', type: Post },
  ]
}>

export const permix = createPermix<PermissionsDefinition>()

The returned helper does not hold any permission state at module scope — every request gets its own isolated instance.

By default the instance is stored on the request context under '__permix'. Call .contextKey('permissions') if you need a custom key (e.g. when running multiple Permix instances side by side).

Setup per request

Register setupMiddleware() as a global request middleware in src/start.ts so it runs for every request and creates a fresh, request-scoped instance. The callback receives the request, so you can read cookies, headers, or fetch the user.

src/start.ts
import { createStart } from '@tanstack/react-start'
import { getSession } from './lib/auth'
import { permix } from './lib/permix'

export const startInstance = createStart(() => ({
  requestMiddleware: [
    permix.setupMiddleware(async ({ request }) => {
      const session = await getSession(request)

      return {
        post: {
          create: !!session,
          read: true,
          update: post => post?.authorId === session?.userId,
          delete: session?.role === 'admin',
        },
      }
    }),
  ],
}))

You can also attach setupMiddleware() to a specific server route's middleware array instead of registering it globally, if only some routes need permissions.

Server-only imports in the setup callback

TanStack Start strips server code from the client bundle by rewriting createMiddleware().server(...) calls it finds in your source files. setupMiddleware() makes that call inside the Permix package, so the compiler never sees a .server() boundary in your module — the setup callback and everything it imports stay in the client graph.

With plain isomorphic rules that's harmless. But as soon as the callback pulls in server-only dependencies — an auth library, a database client, node: builtins — they leak into the browser and fail at runtime:

  • Buffer is not defined
  • Module "events" has been externalized for browser compatibility
  • database drivers appearing in the client bundle

When the callback needs server-only imports, write the .server() boundary yourself and pass in createSetupHandler() — the same setup logic, but placed where the compiler can strip it:

src/start.ts
import { createMiddleware, createStart } from '@tanstack/react-start'
import { auth } from './lib/auth' // server-only: stays out of the client bundle
import { permix } from './lib/permix'

const permixMiddleware = createMiddleware().server(
  permix.createSetupHandler(async ({ request }) => {
    const session = await auth.api.getSession({ headers: request.headers })

    return {
      post: {
        create: !!session,
        read: true,
        update: post => post?.authorId === session?.userId,
        delete: session?.role === 'admin',
      },
    }
  }),
)

export const startInstance = createStart(() => ({
  requestMiddleware: [permixMiddleware],
}))

createSetupHandler() accepts exactly what setupMiddleware() accepts — a rules object or a callback receiving the request — and produces the same request-scoped instance, hooks included. checkMiddleware() is unaffected: it never closes over your setup imports.

Check on the server

The request-scoped instance lives on TanStack Start's server request context, which is shared with server functions and server routes. Read it with getOrThrow(context) (or get(context)) inside a createServerFn handler and call check():

src/server/posts.ts
import { notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { permix } from '../lib/permix'
import { getPost } from '../lib/posts'

export const getPostPageData = createServerFn()
  .inputValidator((data: { id: string }) => data)
  .handler(async ({ data, context }) => {
    const post = await getPost(data.id)

    if (!post || !permix.getOrThrow(context).check('post.read', post)) {
      throw notFound()
    }

    return { post }
  })

Then call the server function from your route loader:

src/routes/posts.$id.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getPostPageData } from '../server/posts'

export const Route = createFileRoute('/posts/$id')({
  loader: ({ params }) => getPostPageData({ data: { id: params.id } }),
})

Use get(context) instead of getOrThrow(context) when you want a nullable value rather than a PermixNotFoundError if setup didn't run.

permix.get(context) only works where context is the server request context — server functions and server routes. A route loader or beforeLoad receives the router context instead, and runs isomorphically on both server and client. To check permissions there, put an instance on the router context — see Check in beforeLoad and loaders.

Guard server functions

Use checkMiddleware() to enforce a permission before a server function's handler runs:

src/lib/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { permix } from './permix'

export const createPost = createServerFn({ method: 'POST' })
  .middleware([permix.checkMiddleware('post.create')])
  .handler(async () => {
    // Only runs when `post.create` passed.
    // ...
  })

By default a denied check throws a PermixError. Pass an onForbidden handler to createPermix to customise this — for example to throw a redirect().

const permix = createPermix<Definition>({
  onForbidden: ({ path }) => {
    throw new Error(`Forbidden: ${path}`)
  },
})

Add Permix to the router context

beforeLoad and loader never see the server request context, but they do see the router context. Create a core Permix instance inside getRouter() and pass it there.

getRouter() runs once per request on the server and once per tab in the browser, so this instance is request-scoped during SSR and a singleton in the browser — the same lifetime you want for the client cache.

src/router.tsx
import type { PermissionsDefinition } from './lib/permix'
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { createPermix } from 'permix'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  const permix = createPermix<PermissionsDefinition>()

  return createTanStackRouter({
    routeTree,
    context: { permix },
  })
}

Type the context on the root route with createRootRouteWithContext:

src/routes/__root.tsx
import type { Permix } from 'permix'
import type { PermissionsDefinition } from '../lib/permix'
import { createRootRouteWithContext } from '@tanstack/react-router'

export interface RouterContext {
  permix: Permix<PermissionsDefinition>
}

export const Route = createRootRouteWithContext<RouterContext>()({
  // ...
})

Typing the context is not enough on its own — the runtime value must be passed to createTanStackRouter({ context }) too, otherwise context.permix is undefined in every route.

Hydrate it in the root route

Use dehydrate(context) to serialize the request's permissions, then hydrate the router-context instance in the root route's beforeLoad. The server cannot send the instance itself across the boundary — only the JSON state.

beforeLoad and loader are isomorphic — they run on both server and client. Since the request-scoped instance only exists in the server context, dehydrate() must be called inside a createServerFn so it always executes on the server.

src/lib/permix.server.ts
import { createServerFn } from '@tanstack/react-start'
import { permix } from './permix'

export const getPermixState = createServerFn()
  .handler(({ context }) => permix.dehydrate(context))

The root route's beforeLoad runs before every child route's beforeLoad and loader, so hydrating there makes context.permix ready everywhere below it. Returning the state also puts it on the context for the components.

src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { getPermixState } from '../lib/permix.server'
import { Providers } from '../providers'

// `RouterContext` from the previous step.
export const Route = createRootRouteWithContext<RouterContext>()({
  beforeLoad: async ({ context }) => {
    const state = await getPermixState()

    context.permix.hydrate(state)

    return { state }
  },
  component: RootComponent,
})

function RootComponent() {
  const { permix, state } = Route.useRouteContext()

  return (
    <Providers permix={permix} state={state}>
      <Outlet />
    </Providers>
  )
}

Pass the very same instance to PermixProvider so routes and components read one source of truth:

src/providers.tsx
import type { DehydratedState, Permix } from 'permix'
import type { PermissionsDefinition } from './lib/permix'
import { PermixHydrate, PermixProvider } from 'permix/react'

export function Providers({
  permix,
  state,
  children,
}: {
  permix: Permix<PermissionsDefinition>
  state: DehydratedState<PermissionsDefinition>
  children: React.ReactNode
}) {
  return (
    <PermixProvider permix={permix}>
      <PermixHydrate state={state}>{children}</PermixHydrate>
    </PermixProvider>
  )
}

hydrate() restores the boolean state but does not flip isReady on its own — function-based rules are lost during serialization. If you need isReady, or rules that depend on check-time data (update: post => post.authorId === userId), call permix.setup(...) on the client too with the same shape. See the Hydration guide for details.

Check in beforeLoad and loaders

With the instance hydrated on the router context, any route can guard itself without a dedicated server function. The check runs on the server during SSR and on the client on subsequent navigations.

src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/dashboard')({
  beforeLoad: ({ context }) => {
    if (!context.permix.check('post.create')) {
      throw redirect({ to: '/login' })
    }
  },
  component: DashboardComponent,
})

This is a UX guard, not enforcement. The client can always call your server functions directly, so mirror every guard with checkMiddleware() (or a getOrThrow(context).check()) on the server.

Two limits are worth knowing:

  • Hydrated rules are plain booleans, so a data-dependent rule like post.update collapses to its no-data result. Checks that need the entity belong in a server function — or re-run setup() on the client with the full rules.
  • check() throws PermixNotReadyError when the instance has neither been hydrated nor set up, which is what you'll see if the root beforeLoad was skipped.

Use on the client

Read the instance off the router context and pass it to usePermix:

src/lib/use-permix.ts
import { useRouteContext } from '@tanstack/react-router'
import { usePermix as useReactPermix } from 'permix/react'

export function usePermix() {
  const { permix } = useRouteContext({ from: '__root__' })

  return useReactPermix(permix)
}
src/components/edit-button.tsx
import { usePermix } from '../lib/use-permix'

export function EditButton({ post }: { post: { id: string, authorId: string } }) {
  const { check } = usePermix()

  if (!check('post.update', post)) {
    return null
  }

  return <button type="button">Edit post</button>
}

If you prefer the component API, create checkers with createComponents from permix/react — see the React integration for details.

Templates

createPermix exposes the same template() helper as the core API for reusing rule sets:

lib/permix.ts
import { createPermix } from 'permix/tanstack-start'

export const permix = createPermix<{
  post: ['create', 'read', 'update', 'delete']
}>()

export const adminTemplate = permix.template({
  post: { create: true, read: true, update: true, delete: true },
})

export const guestTemplate = permix.template({
  post: { create: false, read: true, update: false, delete: false },
})
src/start.ts
import { adminTemplate, guestTemplate, permix } from './lib/permix'

permix.setupMiddleware(async ({ request }) => {
  const session = await getSession(request)
  return session?.role === 'admin' ? adminTemplate() : guestTemplate()
})

Example

A runnable app wiring all of the above together lives here.

How per-request isolation works

setupMiddleware creates a fresh core instance per request and stores it on TanStack Start's server request context. All subsequent get() / getOrThrow() / check() calls within the same request share that one instance. Across concurrent requests, each gets its own instance — state never leaks between users.

The router-context instance is separate, and isolated for the same reason: getRouter() is called once per request on the server, so each SSR render builds its own router with its own Permix instance. In the browser getRouter() runs once, so that instance doubles as the per-tab client cache.

Server request contextRouter context
Created bysetupMiddleware()getRouter()
Read withpermix.get(context) / getOrThrow(context)context.permix
Available inserver functions, server routesbeforeLoad, loader, components
Rulesfull, including function-basedhydrated booleans (until you setup() on the client)
Trustworthyyes — enforcementno — UX only

API

createPermix<D>(options?)

Returns an object with the following methods:

MethodDescription
setupMiddleware(rules | ({ request }) => rules)A request middleware that creates a per-request instance and runs setup(). Register it globally in src/start.ts or on a server route.
createSetupHandler(rules | ({ request }) => rules)The server handler behind setupMiddleware, for passing to your own createMiddleware().server(...). Use when the callback has server-only imports.
checkMiddleware(...args)A function middleware that enforces a permission check before a server function's handler.
get(context)Read the request-scoped Permix<D> instance from a context object, or null if missing.
getOrThrow(context)Like get, but throws PermixNotFoundError when the instance is missing.
getRules(context)Return the current rules object for the request-scoped instance, or null.
dehydrate(context)Serialize the current request's rules to JSON (for <PermixHydrate> on the client).
template(rules)Create a reusable rule set. Same as the core template.
contextKey(key)Set a custom context key (string or symbol). Chainable; returns the same helper.
keyThe current context key.

Options

OptionDescription
onForbidden({ path, data })Called when checkMiddleware denies a request. Defaults to throwing a PermixError.

Hooks

You can register hooks at the factory level to listen for events across all requests:

permix.hook('check', ({ path, data }) => {
  console.log(`Permission checked: ${path}`, data)
})

On this page