Guide · Next.js on Vercel
Roles and permissions for Next.js apps on Vercel
Next.js gives you server components, route handlers and server actions, but no model for who may do what. Keep roles and grants in Permio and check them on the server, where your data access already is.
Uses Next.js App Router, deployed on Vercel, with the permio TypeScript SDK. Updated November 2026.
Why role checks in Next.js get messy
Most Next.js apps start with a role column on the user and if (user.role === "admin") scattered through pages and actions. That breaks down as soon as roles differ per workspace or project, someone shares one project with a client, or you need to show who has access.
Hiding a button is not access control. Every server action is a public POST endpoint, and every route handler can be called directly, so the check has to run inside them, not only in the page that renders the button. Middleware (called proxy in Next.js 16) can turn away whole sections, but it doesn’t know which project a request touches, so it can’t replace those checks.
Permio replaces the role column with grants on paths. Make someone an editor of /workspaces/42 and they can edit every project in it, including ones created later; give a client a viewer role on one project and they see only that. Each check is one call from the server.
How it fits together
Your auth library (Auth.js, Clerk, Supabase Auth or your own) tells you who is signed in. Before a server component renders protected data, and inside every route handler and server action that reads or changes it, ask Permio whether that user may do the action on that resource. The examples use Auth.js and Prisma (@/auth is your Auth.js config and @/lib/db your Prisma client); any auth library and database work the same way.
The example is a team app: workspaces that contain projects.
Resource URIs follow that shape, /workspaces/{workspaceId} and /workspaces/{workspaceId}/projects/{projectId}, so a role on a
workspace covers all of its projects, and a role on one project covers only that project.
1. Set up Permio
Sign up and use the default project. A Permio project is a container for your permissions, roles and grants, not one of the example
app’s projects. In the dashboard, create these permissions:
| Permission | Lets the user |
|---|---|
project.read | Open a project and see it in lists |
project.write | Rename or edit a project |
workspace.manage | Create projects, invite people, change roles |
Then create three roles that bundle them:
| Role | Permissions |
|---|---|
viewer | project.read |
editor | project.read,project.write |
owner | project.read,project.write,workspace.manage |
Grants refer to roles by ID; the code below looks the IDs up by name, so there’s nothing to copy. Open API Keys and create a full-access key, because the app grants roles as well as checking them. Copy it now; it’s only shown once. See API keys for check-only keys and key hygiene.
2. Add the key to Vercel
Link the folder to your Vercel project, then add the API key for each environment you deploy, marked as Sensitive. Don’t give it a NEXT_PUBLIC_ prefix: that would put it in the browser bundle.
vercel link
vercel env add PERMIO_API_KEY
vercel env pull .env.local vercel env pull fetches the Development values for local use. Optionally, keep preview and production grants apart: create a Permio project for each, add a key from each one, and set PERMIO_PROJECT per environment to that project’s name. The client below reads it, and falls back to default.
3. Create a server-only client
The server-only import makes the build fail if a client component ever imports this file. The client is created on first use, so next build works on machines without the key.
lib/permio.ts
import "server-only";
import { redirect } from "next/navigation";
import { Permio } from "permio";
import { auth } from "@/auth";
let client: Permio | undefined;
// PERMIO_PROJECT names the Permio project that holds your grants, not one of your app's projects.
export function permio(): Permio {
client ??= new Permio({
apiKey: process.env.PERMIO_API_KEY!,
project: process.env.PERMIO_PROJECT ?? "default",
});
return client;
}
// Grants take role IDs. Look them up by name once per server instance.
let roleIds: Map<string, string> | undefined;
export async function roleId(name: string): Promise<string> {
if (!roleIds) {
const found = new Map<string, string>();
for await (const role of permio().listRoles()) found.set(role.name, role.id);
roleIds = found;
}
const id = roleIds.get(name);
if (!id) throw new Error("No Permio role named " + name);
return id;
}
// The signed-in user's ID, the Permio principal.
// With Clerk: const { userId } = await auth() from "@clerk/nextjs/server".
export async function requireUserId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return session.user.id;
} Auth.js leaves the user’s ID off the session by default, so session.user.id would be empty and every request would redirect to the login page. Copy it in with a session callback:
auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
callbacks: {
// With JWT sessions, token.sub is the user's ID.
session({ session, token }) {
if (token.sub) session.user.id = token.sub;
return session;
},
},
});4. Check in server components
Check before you load the data. Answering “not found” instead of “forbidden” avoids telling people which projects exist. resource() builds the URI and percent-encodes the IDs, so build URIs with it everywhere.
app/workspaces/[workspaceId]/projects/[projectId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { resource } from "permio";
import { db } from "@/lib/db";
import { permio, requireUserId } from "@/lib/permio";
type Params = Promise<{ workspaceId: string; projectId: string }>;
export default async function ProjectPage({ params }: { params: Params }) {
const { workspaceId, projectId } = await params;
const userId = await requireUserId();
const uri = resource("workspaces", workspaceId, "projects", projectId);
const [canRead, canEdit] = await Promise.all([
permio().check({ principal: userId, resource: uri, permission: "project.read" }),
permio().check({ principal: userId, resource: uri, permission: "project.write" }),
]);
if (!canRead) notFound();
const project = await db.project.findFirst({ where: { id: projectId, workspaceId } });
if (!project) notFound();
return (
<main>
<h1>{project.name}</h1>
{canEdit && (
<Link href={"/workspaces/" + workspaceId + "/projects/" + projectId + "/settings"}>
Edit
</Link>
)}
</main>
);
} 5. Check inside every server action
The page above hides the edit link, but the action behind it must check again, because anyone can call it directly. Treat its arguments as untrusted input, and return a denial as a normal result the form can show.
app/workspaces/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { resource } from "permio";
import { db } from "@/lib/db";
import { permio, requireUserId, roleId } from "@/lib/permio";
export async function renameProject(workspaceId: string, projectId: string, name: string) {
const userId = await requireUserId();
if (!name.trim()) return { error: "Give the project a name." };
const allowed = await permio().check({
principal: userId,
resource: resource("workspaces", workspaceId, "projects", projectId),
permission: "project.write",
});
if (!allowed) return { error: "You can't edit this project." };
await db.project.update({ where: { id: projectId, workspaceId }, data: { name } });
revalidatePath("/workspaces/" + workspaceId);
return { ok: true };
} Route handlers work the same way: check, and return new Response("Forbidden", { status: 403 }) when it fails. If you’d rather throw, permio().assert() throws PermioDeniedError; catch it at the edge of the action, because Next.js hides thrown messages in production.
6. Grant roles: owner on create, then invites
Whoever creates a workspace gets the owner role on it, and so on every project inside it. If the grant fails, delete the row so you never keep a workspace nobody can open. Inviting a teammate is another grant, allowed only with workspace.manage.
app/workspaces/actions.ts
export async function createWorkspace(name: string) {
const userId = await requireUserId();
const workspace = await db.workspace.create({ data: { name, createdBy: userId } });
try {
await permio().grant({
principal: userId,
resource: resource("workspaces", workspace.id),
role: await roleId("owner"),
});
} catch (err) {
await db.workspace.delete({ where: { id: workspace.id } });
throw err;
}
revalidatePath("/workspaces");
return workspace.id;
}
export async function inviteMember(workspaceId: string, memberId: string, role: string) {
const userId = await requireUserId();
if (role !== "viewer" && role !== "editor") return { error: "Unknown role." };
const workspace = resource("workspaces", workspaceId);
const allowed = await permio().check({
principal: userId,
resource: workspace,
permission: "workspace.manage",
});
if (!allowed) return { error: "You can't invite people here." };
await permio().grant({ principal: memberId, resource: workspace, role: await roleId(role) });
return { ok: true };
} To share a single project instead, grant on its URI. For someone who hasn’t signed up yet, store the invite and make the grant when they first sign in.
7. Filter lists to what the user can see
With Prisma, the answer from listAccess turns straight into a where clause: no filter at all when the user can read the whole workspace, or an id in [...] for the projects shared with them.
lib/projects.ts
import "server-only";
import { resource } from "permio";
import { db } from "@/lib/db";
import { permio } from "@/lib/permio";
export async function visibleProjects(userId: string, workspaceId: string) {
const access = await permio().listAccess({
principal: userId,
permission: "project.read",
under: resource("workspaces", workspaceId, "projects"),
});
if (access.coversAll) {
return db.project.findMany({ where: { workspaceId }, orderBy: { createdAt: "asc" } });
}
// Projects shared one by one: "/workspaces/{w}/projects/{p}".
// Grants made with a "_" wildcard aren't prefixes; they're left out (safe, not shown).
const ids = access.toPrefixes()
.map((uri) => uri.split("/"))
.filter((s) => s.length === 5)
.map((s) => decodeURIComponent(s[4]));
return db.project.findMany({
where: { workspaceId, id: { in: ids } },
orderBy: { createdAt: "asc" },
});
} The permio client itself only needs fetch, so it also works in the Edge runtime and in proxy, for coarse checks like “may this user open /admin”. On Vercel’s default Node runtime, the client is reused across requests.
Let your AI assistant do it
Building with Cursor, Claude Code, Lovable, Bolt or v0? Paste this prompt and it will make the changes above in your own code, using your data model.
Add authorisation to this Next.js app with Permio (perms.io), using the `permio` npm package.
Read these first:
- https://perms.io/guides/nextjs-vercel (how Permio fits a Next.js App Router app on Vercel)
- https://perms.io/llms-full.txt (model, APIs and SDK usage)
- https://docs.perms.io/sdk/typescript/
Rules:
- Keep our existing login. The Permio principal is the signed-in user's ID.
- The Permio API key is a server-side secret. Never put it in client code or a public env var.
- Resource URIs follow our data model, e.g. /workspaces/{id}/projects/{id}, built with the SDK's resource() helper.
- "Project" in the Permio client options is a Permio project (a container for grants), not one of our app's projects.
- Grants take role IDs: look them up by name with permio.listRoles() once and cache them.
- A denied check is a normal answer: return 403 (or the platform's permission-denied error), not a 500.
- Before moving existing queries, show me the plan: which reads and writes move to the server and which stay.
Then:
1. Create lib/permio.ts with import "server-only", a lazily created Permio client from process.env.PERMIO_API_KEY (project from PERMIO_PROJECT, default "default"), a cached role-ID lookup by name, and requireUserId() using our auth library. Never use a NEXT_PUBLIC_ variable for the key.
2. Make sure the session carries the user's ID. With Auth.js and JWT sessions, add a session callback that sets session.user.id = token.sub; without it every check redirects to login.
3. In every server component that loads protected data, check project.read (or the right permission) first and call notFound() when denied.
4. In every server action and route handler that reads or changes data, validate the arguments and call permio().check() inside the action itself, not only in the page. Return an error result from actions and 403 from route handlers when denied.
5. When a workspace is created, grant its creator the owner role on /workspaces/{id}. Delete the row if the grant fails. Add an inviteMember action that checks workspace.manage before granting viewer or editor.
6. Filter list queries with permio().listAccess() and coversAll / toPrefixes(), not by loading every row.
7. Tell me which environment variables to add in Vercel for each environment.
Finally, list the permissions and roles I need to create in the Permio dashboard. Frequently asked questions
Should I check permissions in middleware?
Only for coarse gates, such as “may this user open /admin at all”. Middleware (proxy in Next.js 16) doesn’t know which project a request touches, and server actions and route handlers can be called directly, so check inside them too.
Can I call Permio from a client component?
No. The API key must stay on the server. Check in a server component, route handler or server action and pass the result (for example canEdit) down as a prop.
Does it work in the Edge runtime?
The permio client does: it only needs fetch, so it runs in the Node and Edge runtimes and in Vercel Functions. Whether the rest of your code does depends on your database and auth libraries.
Which auth libraries does this work with?
Any of them. Permio does authorisation only. Pass the user ID from Auth.js, Clerk, Supabase Auth, Firebase Auth or your own login as the principal.
Does this work with v0 or Cursor?
Yes. Paste the prompt on this page into the assistant. It explains where checks go in an App Router app and keeps the key out of client code.
Try it on your own app. The free plan needs no payment details, and the quickstart gets you to a passing check in a few minutes.
Further reading
- Resources and inheritance: designing URIs, and the
_wildcard. - TypeScript SDK:
checkDetailed,batchCheck,whoHasAccessfor share dialogs, and error handling. - Groups: grant to a whole team at once.
- Roles and permissions for Supabase apps
- Roles and permissions for Firebase apps
- All guides