Permix

tRPC

Learn how to use Permix with tRPC

Overview

Permix provides a middleware for tRPC that allows you to easily check permissions in your procedures. The middleware can be created using the createPermix function.

Before getting started with tRPC integration, make sure you've completed the initial setup steps in the Quick Start guide.

Setup

Here's a basic example of how to use the Permix middleware with tRPC:

import { initTRPC } from '@trpc/server'
import { createPermix } from 'permix/trpc'

interface Post {
  id: string
  authorId: string
  title: string
  content: string
}

interface Context {
  user: {
    id: string
    role: string
  }
}

// Initialize tRPC
const t = initTRPC.context<Context>().create()

// Create your Permix instance with a custom context key
const permix = createPermix<{
  post: ['create', 'read', 'update', 'delete']
}>().contextKey('permissions')

// Create a protected procedure with Permix
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  const isAdmin = ctx.user.role === 'admin'

  return next({
    ctx: permix.setupContext({
      post: {
        create: true,
        read: true,
        update: isAdmin,
        delete: isAdmin
      }
    })
  })
})

Call .contextKey('name') on the returned builder to set a custom context key — its literal type is inferred automatically. Omit it to use the default key 'permix'. Pass onForbidden as an option to createPermix itself.

Checking Permissions

Use the checkMiddleware function in your tRPC procedures to check permissions:

const router = t.router({
  createPost: protectedProcedure
    .use(permix.checkMiddleware('post.create'))
    .mutation(({ input }) => {
      // Create post logic here
      return { success: true }
    }),

  updatePost: protectedProcedure
    .use(permix.checkMiddleware(c => c('post.read') && c('post.update')))
    .mutation(({ input }) => {
      // Update post logic here
      return { success: true }
    }),

  deletePost: protectedProcedure
    .use(permix.checkMiddleware('post.delete'))
    .mutation(({ input }) => {
      // Delete post logic here
      return { success: true }
    }),
})

Accessing Permix in Procedures

Permix is automatically added to your tRPC context under the key you specified, so you can access it directly:

const router = t.router({
  getPosts: protectedProcedure
    .query(({ ctx }) => {
      // Check permissions manually
      if (ctx.permissions.check('post.read')) {
        return getAllPosts()
      }

      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'You do not have permission to read posts'
      })
    })
})

The ctx.permissions object provides:

  • check: Synchronously check a permission
  • dehydrate: Serialize the current rules for client hydration
  • template: Create reusable permission templates

Using Templates

Permix provides a template helper to create reusable permission rule sets:

// Create a template for admin permissions
const adminTemplate = permix.template({
  post: {
    create: true,
    read: true,
    update: true,
    delete: true
  }
})

// Create a template for regular user permissions
const userTemplate = permix.template({
  post: {
    create: true,
    read: true,
    update: false,
    delete: false
  }
})

// Use templates in your middleware
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  return next({
    ctx: permix.setupContext(
      ctx.user.role === 'admin'
        ? adminTemplate()
        : userTemplate()
    ),
  })
})

Custom Error Handling

By default, the middleware throws a TRPCError with code FORBIDDEN. You can customize this behavior with the onForbidden option, which is a terminal handler — it receives the middleware opts (including next) plus the check context, and controls the outcome:

Throw a Custom Error

const permix = createPermix<Definition>({
  onForbidden: ({ path, ctx }) => {
    throw new TRPCError({
      code: 'FORBIDDEN',
      message: `User ${ctx.user.id} doesn't have permission for ${path}`,
    })
  },
}).contextKey('permissions')

Allow Through

You can also let denied requests through by calling next():

const permix = createPermix<Definition>({
  onForbidden: ({ path, next }) => {
    console.warn(`Permission denied for ${path}, allowing through`)
    return next()
  },
}).contextKey('permissions')

The onForbidden handler receives:

  • path: The permission path that was checked (e.g. 'post.create')
  • data: Optional data passed to the check
  • ctx: Your tRPC context object
  • next: The middleware next function — call it to allow the request through

Advanced Usage

Async Permission Rules

You can use async functions in your permission setup:

const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  const userPermissions = await getUserPermissions(ctx.user.id)

  return next({
    ctx: permix.setupContext({
      post: {
        create: userPermissions.canCreatePosts,
        read: userPermissions.canReadPosts,
        update: userPermissions.canUpdatePosts,
        delete: userPermissions.canDeletePosts
      }
    })
  })
})

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