DEV Community

Tanay Karnik
Tanay Karnik

Posted on • Originally published at saas-boilerplate.dev

Nuxt Authorization: How to Implement Team Role-Based Access Control in Nuxt 3

If you're building a multi-tenant SaaS in Nuxt 3, you'll need a robust permissions system.
Here's how I built a type-safe RBAC system that scales from small teams to enterprise, using Prisma and tRPC.

The Stack

Basic Setup

First, install the authorization module:

pnpx nuxi@latest module add nuxt-authorization
Enter fullscreen mode Exit fullscreen mode

Client-Side Authorization

Set up a plugin to resolve the user on the client:

export default defineNuxtPlugin({
  name: "authorization-resolver",
  parallel: true,
  setup() {
    return {
      provide: {
        authorization: {
          resolveClientUser: () => useAuth().data.value?.user,
        },
      },
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

Server-Side Authorization

Similarly for the server:

import { getServerSession } from "#auth";

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook("request", async (event) => {
    event.context.$authorization = {
      resolveServerUser: async () => {
        return (await getServerSession(event))?.user;
      },
    };
  });
});
Enter fullscreen mode Exit fullscreen mode

Defining Type-Safe Abilities

Here's how we define shared abilities that work on both client and server:

interface User {
  id: string;
  teams?: string[];
  permissions?: Record<string, string[]>;
}

const hasTeamPermission = (
  user: User | null,
  teamId: string,
  permission: string,
): boolean =>
  !!user?.teams?.includes(teamId) &&
  (user?.permissions?.[teamId] || []).includes(permission);

export const listTeams = defineAbility(() => true);

export const getTeamDetails = defineAbility(
  (user: User, teamId: string) => !!(teamId && user?.teams?.includes(teamId)),
);

export const updateTeamDetails = defineAbility(
  (user: User | null, teamId: string) =>
    hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.UPDATE),
);
Enter fullscreen mode Exit fullscreen mode

Database Schema

Your Prisma schema needs to support roles and permissions:

model TeamMembership {
  id     String @id @default(cuid())
  role Role @relation(fields: [roleId], references: [id])
  // [...]
}

model Role {
  id           String  @id @default(cuid())
  teamId       String?
  name         String
  description  String?
  isDefault    Boolean @default(false)
  isSystemRole Boolean @default(false)
  permissions Permission[]
  // [...]
}

model Permission {
  id          String  @id @default(cuid())
  title       String
  description String?
  action      String
  roleId      String
  // [...]
}
Enter fullscreen mode Exit fullscreen mode

Using Abilities in Components

Check permissions in your Vue components:

<Can :ability="deleteTeamAbility" :args="[team?.id || '']">
  <!-- Protected content here -->
</Can>
Enter fullscreen mode Exit fullscreen mode

Type-Safe API Authorization

Create a tRPC procedure for checking abilities:

export const abilityProcedure = protectedProcedure.use(async (opts) => {
  const { ctx } = opts;
  return opts.next({
    ctx: {
      ...ctx,
      allows: async function allow<Ability extends BouncerAbility<any>>(
        ability: Ability,
        ...args: BouncerArgs<Ability>
      ) {
        return await allows(ctx.event, ability, ...args);
      },
      authorize: async function auth<Ability extends BouncerAbility<any>>(
        ability: Ability,
        ...args: BouncerArgs<Ability>
      ) {
        try {
          await authorize(ctx.event, ability, ...args);
        } catch (error) {
          throw new TRPCError({
            code: "FORBIDDEN",
            message: error instanceof Error ? error.message : "Not authorized",
          });
        }
      },
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

Use it in your API routes:

{
  get: abilityProcedure
    .input(
      z.object({
        teamIdentifier: z.string(),
      }),
    )
    .query(async ({ ctx: { authorize, user, prisma }, input }) => {
      await authorize(getTeamDetails, team.id);
      // Protected logic here
    }),
}
Enter fullscreen mode Exit fullscreen mode

Why this works well

  • Fully type-safe from database to UI
  • No external authorization service needed
  • Works seamlessly with any auth provider
  • Scales from simple to complex permission structures

Try it yourself

Want to see this RBAC system in action? This exact implementation is part of my Nuxt SaaS boilerplate.

If you're building a multi-tenant SaaS, check it outโ€”it comes with everything you need: type-safe APIs using tRPC, team management, authentication, billing, and more. Every feature is built with the same attention to developer experience as this permissions system.

Top comments (0)