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. The client side reuses 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:
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.
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.
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():
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:
import { createFileRoute } from '@tanstack/react-router'
import { getPostPageData } from '../server/posts'
export const Route = createFileRoute('/posts/$id')({
loader: ({ params }) => getPostPageData({ data: { id: params.id } }),
})Don't call permix.get(context) directly inside a route loader or beforeLoad. Those run isomorphically (on both server and client), and the context they receive is the router context — not the server request context where setupMiddleware() stored the instance. Always do server checks inside a createServerFn (or a server route) so they execute on the server with access to the instance.
Use get(context) instead of getOrThrow(context) when you want a nullable value rather than a PermixNotFoundError if setup didn't run.
Guard server functions
Use checkMiddleware() to enforce a permission before a server function's handler runs:
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}`)
},
})Send permissions to the client
Use dehydrate(context) to serialize the request's permissions and hand them to a client provider. The server cannot send the Permix instance itself across the boundary — only the JSON state.
Route loaders in TanStack Start are isomorphic — they run on both server and client. Since the Permix instance only exists in the server context, you must call dehydrate() inside a createServerFn to ensure it always executes on the server.
import { createServerFn } from '@tanstack/react-start'
import { permix } from './permix'
export const getPermixState = createServerFn()
.handler(({ context }) => permix.dehydrate(context))import { createRootRoute, Outlet } from '@tanstack/react-router'
import { getPermixState } from '../lib/permix.server'
import { Providers } from '../providers'
export const Route = createRootRoute({
loader: async () => ({ state: await getPermixState() }),
component: RootComponent,
})
function RootComponent() {
const { state } = Route.useLoaderData()
return (
<Providers state={state}>
<Outlet />
</Providers>
)
}import type { DehydratedState } from 'permix'
import type { PermissionsDefinition } from './lib/permix'
import { createPermix } from 'permix'
import { PermixHydrate, PermixProvider } from 'permix/react'
// One singleton per browser tab, reusing the same definition as the server.
const permix = createPermix<PermissionsDefinition>()
export function Providers({
state,
children,
}: {
state: DehydratedState<any>
children: React.ReactNode
}) {
return (
<PermixProvider permix={permix}>
<PermixHydrate state={state}>{children}</PermixHydrate>
</PermixProvider>
)
}
export { permix }hydrate() restores the boolean state but does not flip isReady on its own — function-based rules are lost during serialization. If you need isReady on the client, call permix.setup(...) on the client too with the same shape. See the Hydration guide for details.
Use on the client
From any client component, import the singleton from src/providers.tsx and the hooks/components from permix/react:
import { usePermix } from 'permix/react'
import { permix } from '../providers'
export function EditButton({ post }: { post: { id: string, authorId: string } }) {
const { check } = usePermix(permix)
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:
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 },
})import { adminTemplate, guestTemplate, permix } from './lib/permix'
permix.setupMiddleware(async ({ request }) => {
const session = await getSession(request)
return session?.role === 'admin' ? adminTemplate() : guestTemplate()
})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.
API
createPermix<D>(options?)
Returns an object with the following methods:
| Method | Description |
|---|---|
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. |
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. |
key | The current context key. |
Options
| Option | Description |
|---|---|
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)
})