if (user.role) == "admin"
<Gate role="admin">
Gatehouse is drop-in role-based access control for Next.js.
Define roles once. Protect everything from UI to API.
// app/api/projects/route.ts
export async function POST(req: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
if (session.user.role !== "admin" && session.user.role !== "owner") {
return new Response("Forbidden", { status: 403 });
}
// ... create project
}
// components/Dashboard.tsx
function Dashboard({ user }) {
return (
<div>
{user.role === "admin" || user.role === "owner" ? (
<AdminPanel />
) : null}
{user.role !== "viewer" ? (
<CreateButton />
) : null}
</div>
);
}
// app/api/members/route.ts
export async function DELETE(req: Request) {
const session = await getSession();
if (session?.user.role !== "owner") {
return new Response("Forbidden", { status: 403 });
}
// ... remove member
}
Three files. Three different ways to check roles.
No central definition. No way to audit.
Add a new role? Find every if-statement.
// lib/gatehouse.ts
import { createGatehouse } from "gatehouse";
export const gh = createGatehouse({
roles: {
owner: ["*"],
admin: ["project:*", "member:invite", "member:remove"],
member: ["project:read", "project:create", "task:*"],
viewer: ["project:read", "task:read"],
},
});
// components/Dashboard.tsx
import { Gate } from "gatehouse/react";
<Gate role="admin">
<AdminPanel />
</Gate>
<Gate allow="project:create">
<CreateButton />
</Gate>
// app/api/projects/route.ts
import { withGate } from "gatehouse/next";
import { gate } from "@/lib/gate";
export const POST = withGate(async (request) => {
await gate("project:create");
// ... create project
return Response.json({ ok: true });
});
One definition. Enforced everywhere.
Add a new role? One file.
// lib/gatehouse.ts
import { createGatehouse } from "gatehouse";
export const gh = createGatehouse({
roles: {
owner: ["*"],
admin: ["project:*", "member:invite", "member:remove"],
member: ["project:read", "project:create", "task:*"],
viewer: ["project:read", "task:read"],
},
});
Roles are ordered by rank — first is highest. Wildcards work: project:* matches project:read, project:create, etc.
// lib/gate.ts
import { createServerGate } from "gatehouse/next";
import { gh } from "./gatehouse";
import { auth } from "./auth"; // your existing auth
export const gate = createServerGate({
gatehouse: gh,
resolve: async () => {
const session = await auth();
if (!session) return null;
return { role: session.user.role };
},
});
The resolve function bridges your auth provider to Gatehouse. Return { role } or null. That's the only contract.
// app/api/projects/route.ts
import { withGate } from "gatehouse/next";
import { gate } from "@/lib/gate";
export const POST = withGate(async (request) => {
await gate("project:create");
return Response.json({ ok: true });
});
gate() throws 401 (not authenticated) or 403 (not authorized). No if-statements.
That's it. There is no step four.
// lib/gate.ts
import { createServerGate } from "gatehouse/next";
import { clerkResolver } from "gatehouse/adapters/clerk";
import { gh } from "./gatehouse";
export const gate = createServerGate({
gatehouse: gh,
resolve: clerkResolver(),
});
// lib/gate.ts
import { createServerGate } from "gatehouse/next";
import { supabaseResolver } from "gatehouse/adapters/supabase";
import { gh } from "./gatehouse";
import { createClient } from "@/lib/supabase/server";
export const gate = createServerGate({
gatehouse: gh,
resolve: supabaseResolver({ createClient }),
});
// lib/gate.ts
import { createServerGate } from "gatehouse/next";
import { authjsResolver } from "gatehouse/adapters/authjs";
import { gh } from "./gatehouse";
import { auth } from "./auth";
export const gate = createServerGate({
gatehouse: gh,
resolve: authjsResolver({ auth }),
});
Gatehouse doesn't replace your auth. It completes it.
Using something else? Write a resolve function — it's one async callback.
import { Gate } from "gatehouse/react";
// Show/hide based on permission
<Gate allow="project:create">
<CreateProjectButton />
</Gate>
// With fallback
<Gate
allow="project:create"
fallback={<UpgradePrompt />}
>
<CreateProjectButton />
</Gate>
// Hooks for imperative checks
const canCreate = useGate("project:create");
const role = useRole(); // "admin" | null
import { gate } from "@/lib/gate";
// Throws 401 or 403
await gate("project:create");
// Soft check — returns null instead
// of throwing
const subject = await gate.check(
"project:create"
);
// Multiple permissions
await gate.all([
"project:edit",
"project:delete",
]);
await gate.any([
"project:edit",
"project:create",
]);
// Role check
await gate.role("admin");
// middleware.ts
import { createMiddleware }
from "gatehouse/next";
export default createMiddleware({
protected: [
"/dashboard/:path*",
"/api/projects/:path*",
],
isAuthenticated: (req) =>
!!req.cookies.get("session"),
loginUrl: "/login",
});
export const config = {
matcher: [
"/dashboard/:path*",
"/api/projects/:path*",
],
};
const gh = createGatehouse({
roles: {
owner: ["*"],
admin: ["project:*"],
viewer: ["project:read"],
},
});
gh.can("owner", "project:create"); // ✓ true
gh.can("viewer", "project:create"); // ✓ false
gh.can("superadmin", "project:read"); // ✗ Type error: "superadmin" is not assignable
// ^^^^^^^^^^
// Argument of type '"superadmin"' is not assignable to
// parameter of type '"owner" | "admin" | "viewer"'
Roles are inferred from your config object.
Invalid roles fail at compile time, not at runtime.
No enums, no separate type file, no codegen.
CASL is a powerful, mature authorization library with support for attribute-based access control, MongoDB-style conditions, and framework adapters. If you need ABAC or per-field access rules, CASL is the right choice.
Gatehouse is narrower on purpose. It does RBAC — roles, permissions, wildcards, hierarchy. The API surface is smaller, the setup is faster, and the Next.js integration (React components, hooks, middleware, App Router server gates) is first-class rather than bolted on.
Choose CASL if you need attribute-based rules.
Choose Gatehouse if you need role-based rules in Next.js.
You will. And it'll work. And then six months later you'll have
if (user.role === 'admin') checks in 47 files, a bug where the
intern page doesn't check roles at all, and a PR where someone
adds a new role and misses three routes.
Gatehouse is what you'd build if you did it right the first time. One file, one definition, enforced everywhere. If you outgrow it, rip it out — it's MIT-licensed and your roles are plain objects. There's no migration.
Policy-as-a-service platforms are excellent for complex authorization at enterprise scale — multi-tenant ABAC, policy versioning, audit logs, external policy decisions.
Gatehouse is a library, not a service. No infrastructure, no network calls, no sidecar. It runs in your Next.js process. If your RBAC needs are "four roles, a permission hierarchy, and consistent enforcement" — Gatehouse is the right weight class.
createGatehouse(config) Create a Gatehouse instance from a roles config
.can(role, permission) Check if a role has a permission
.canAll(role, permissions[]) Check if a role has all permissions
.canAny(role, permissions[]) Check if a role has any permission
.isAtLeast(role, minRole) Check role hierarchy (rank comparison)
.permissionsFor(role) Get all permissions for a role
.roles All role names, ordered by rank
.config The raw role definitions object
<GatehouseProvider> Context provider — wraps your app
gatehouse={gh} Your Gatehouse instance
resolve={() => subject | null} Async function returning { role } or null
<Gate> Declarative permission/role gate
allow="permission" Single permission check
role="roleName" Role check (minimum rank)
allOf={["perm1", "perm2"]} All permissions required
anyOf={["perm1", "perm2"]} Any permission sufficient
fallback={<Component />} Shown when denied
loading={<Component />} Shown while resolving
useGate(permission) Hook — returns boolean
useRole() Hook — returns role string or null
usePermissions() Hook — returns permission string array
useGatehouse() Hook — returns { gatehouse, subject, loading }
createServerGate(config) Create a server-side gate
gate() Require authentication (throws 401)
gate(permission) Require permission (throws 403)
gate.all(permissions[]) Require all permissions
gate.any(permissions[]) Require any permission
gate.role(roleName) Require minimum role
gate.check(permission?) Soft check (returns subject or null)
withGate(handler) Wrap a route handler — catches GatehouseError
Returns 401/403 JSON responses automatically
createMiddleware(config) Create edge middleware for route protection
protected: string[] Route patterns to protect
isAuthenticated: (req) => bool Auth check function
loginUrl?: string Redirect target (default: "/login")
clerkResolver(options?) Reads role from Clerk publicMetadata
roleKey?: string Metadata key (default: "role")
defaultRole?: string Fallback role (default: "viewer")
supabaseResolver(options) Reads role from Supabase user/app metadata
createClient: () => client Your Supabase server client factory
source?: string | { table } "app_metadata" | "user_metadata" | { table, column }
roleKey?: string Metadata key (default: "role")
defaultRole?: string Fallback role (default: "viewer")
authjsResolver(options) Reads role from Auth.js session
auth: () => session | null Your Auth.js auth() function
defaultRole?: string Fallback role (default: "viewer")