You have auth.
You need authorization.

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.

npm install gatehouse Copied!
View on GitHub

Hand-rolled vs Steel-forged

Without Gatehouse

// 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.

With Gatehouse

// 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.

Set up in three files

1 Define your roles
// 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.

2 Connect your auth provider
// 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.

3 Protect your routes
// 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.

Plugs into Clerk, Supabase, and Auth.js

Clerk

// 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(),
});

Supabase

// 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 }),
});

Auth.js

// 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.

One role definition. Three enforcement layers.

React Components

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

API Routes

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");

Edge Middleware

// 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*",
  ],
};

Your roles are your types

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.

Honest comparisons

"Why not CASL?"

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.

"Why not roll your own?"

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.

"Why not a policy service like Oso or Cerbos?"

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.

Complete API

Core

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

React

<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 }

Next.js

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")

Adapters

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")