Next.js 16 ships with proxy.ts, React Compiler, and React 19.2. Your access control should be as modern as your stack.
In this article, you will learn:
- What RBAC is and why it belongs in your auth layer, not your database
- How Kinde models roles and permissions and why that maps cleanly to Next.js 16
- How to set up the Kinde Next.js SDK in a Next.js 16.2 project
- How to configure the auth handler and environment variables
- How to protect routes using
proxy.ts— the new Next.js 16 network boundary file - How to define roles and permissions in the Kinde dashboard
- How to enforce permissions in React Server Components
- How to enforce permissions in Route Handlers (API routes)
- How to render conditional UI based on a user's role
- How to handle the
use cachedirective safely alongside permission checks
Let's dive in!
Prerequisites
Before starting this tutorial, make sure you have:
- Node.js 18.18 or later installed
- A Kinde account — free at kinde.com, no credit card required
- Familiarity with Next.js App Router fundamentals
- Basic knowledge of TypeScript
What Is RBAC and Why Does It Belong in Auth?
Role-Based Access Control (RBAC) is the mechanism that decides what authenticated users are allowed to do. Authentication answers "who are you?" — RBAC answers "what can you do?"
The wrong place to implement RBAC is your database. Many teams check a role column on the user record mid-request, which means a round-trip to the database every time your application needs to make an access decision. That is slow, adds complexity, and couples your permission logic to your data layer.
The right place is your auth token. When a user logs in, Kinde issues a JWT that includes their roles and the exact permissions those roles grant. Your application reads from the token. No extra database query. No round-trip. The permission answer is cryptographically included at the start of every authenticated request.
This is what makes Kinde's RBAC model a natural fit for Next.js 16's Server Components architecture — every Server Component can check permissions synchronously from the session without adding a waterfall of database queries.
How Kinde's RBAC Model Works
Kinde structures access control in three layers that are worth understanding before you write any code:
Permissions are the granular capabilities your application checks: posts:create, posts:delete, users:manage, billing:view. You define these in the Kinde dashboard. They are the atoms of your access system — every access decision in your code should reference a permission key, not a role name.
Roles are named bundles of permissions. admin, editor, viewer are roles. You define roles and attach permissions to them in Kinde. Roles exist so you can assign a coherent set of capabilities to a user in one operation.
Users are assigned roles. When a user logs in, their JWT token contains both their roles and the expanded set of permissions those roles grant. Your code checks permissions — the role is just the management abstraction that groups them.
The access check in your Next.js components is always getPermission("posts:create"), never getRole() === "admin". This decoupling means you can restructure your roles in the Kinde dashboard without touching a line of application code.
What's New in Next.js 16 That Affects This Tutorial
Next.js 16 introduced proxy.ts as the new file for handling network boundary logic, replacing middleware.ts for most use cases. The middleware.ts file is deprecated in 16 and will be removed in a future version.
For RBAC, this matters in one specific way: proxy.ts runs before any route in your application and is where you redirect unauthenticated users. The Kinde withAuth helper plugs into this file just as cleanly as it did into middleware.ts.
Next.js 16 also stabilised the React Compiler, which automatically memoises components. You do not need to manually wrap permission-checking components in useMemo or memo — the compiler handles it.
One important note about use cache in Next.js 16: do not cache pages or components that render based on user-specific permission data. Cached outputs are shared across users. The code in this tutorial keeps permission-sensitive components outside of use cache boundaries.
Step #1: Create a Next.js 16 Project
Start a fresh Next.js 16.2 project with the App Router and TypeScript:
npx create-next-app@latest my-rbac-app \
--typescript \
--app \
--tailwind \
--src-dir
Navigate into the project directory and install the Kinde Next.js SDK:
cd my-rbac-app
npm install @kinde-oss/kinde-auth-nextjs
Step #2: Configure Kinde and Set Environment Variables
In your Kinde dashboard, navigate to Settings → Applications → Add application and select Back-end web. Give it a name like My Next.js 16 App. Once created, open the application details and copy the following values:
Create a .env.local file in the root of your project:
# Kinde application credentials
KINDE_CLIENT_ID=your_kinde_client_id
KINDE_CLIENT_SECRET=your_kinde_client_secret
KINDE_ISSUER_URL=https://your-subdomain.kinde.com
# Application URLs
KINDE_SITE_URL=http://localhost:3000
KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000
KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard
Replace your_kinde_client_id, your_kinde_client_secret, and your-subdomain with the values from your Kinde dashboard. The KINDE_ISSUER_URL is your Kinde domain, typically https://yourappname.kinde.com.
Next, add the callback URLs in Kinde. Still in your application's settings, find the Callback URLs section and add:
-
Allowed callback URLs:
http://localhost:3000/api/auth/kinde_callback -
Allowed logout redirect URLs:
http://localhost:3000
Step #3: Set Up the Auth Route Handler
Create the Kinde auth handler at app/api/auth/[kindeAuth]/route.ts. This single file handles all of Kinde's auth endpoints — login, logout, register, and the callback:
// app/api/auth/[kindeAuth]/route.ts
import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server";
export const GET = handleAuth();
That is the entire auth route. Kinde handles the OAuth flow, token exchange, session management, and redirect logic. Your application does not need to implement any of this.
Step #4: Wrap the Root Layout with KindeProvider
Open app/layout.tsx and wrap the children with KindeProvider. This is a server component — import from the server path, not the client path:
// layout.ts
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "My RBAC App",
description: "Built with Next.js 16 and Kinde",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
Step #5: Protect Routes Using proxy.ts
In Next.js 16, proxy.ts is the new file for network boundary logic. The middleware.ts file is deprecated — use proxy.ts instead for new projects.
Create proxy.ts in your project root (at the same level as app):
// proxy.ts
import { withAuth } from "@kinde-oss/kinde-auth-nextjs/middleware";
import type { NextRequest } from "next/server";
export default withAuth;
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
Note: If you have an existing project using middleware.ts, it continues to work in Next.js 16 for Edge runtime use cases. But for new projects, proxy.ts is the correct file. The withAuth import path and usage are identical between the two.
Any unauthenticated user who tries to reach a matched route is automatically redirected to Kinde's login page. Once they authenticate, Kinde redirects them back to the page they originally requested.
Step #6: Define Roles and Permissions in Kinde
Before writing any permission-checking code, set up your roles and permissions in the Kinde dashboard.
Navigate to Roles & Permissions → Permissions → Add permission. Create each permission with a resource:action naming convention:
For this tutorial, create these permissions:
| Key | Description |
|---|---|
posts:create |
Create new posts |
posts:update |
Edit existing posts |
posts:delete |
Delete posts permanently |
posts:publish |
Publish or unpublish posts |
users:view |
View the user list |
users:manage |
Edit user details and roles |
Now navigate to Roles & Permissions → Roles → Add role and create three roles:
Assign permissions to each role:
| Permission | Admin | Editor | Viewer |
|---|---|---|---|
posts:create |
✓ | ✓ | |
posts:update |
✓ | ✓ | |
posts:delete |
✓ | ||
posts:publish |
✓ | ✓ | |
users:view |
✓ | ||
users:manage |
✓ |
Assign a role to your own user for testing. Navigate to Users → select your user → Roles → assign admin. You will test the other roles by reassigning later.
Terrific! The permission model is ready. Time to use it in code.
Step #7: Check Permissions in Server Components
getKindeServerSession() is the main SDK helper for checking authentication state and accessing user data in Server Components. It reads directly from the server-side session — no network request, no database query.
Create a protected dashboard page at app/dashboard/page.tsx:
// app/dashboard/page.tsx
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";
import { PermissionGate } from "@/components/PermissionGate";
export default async function DashboardPage() {
const { isAuthenticated, getUser } = getKindeServerSession();
if (!(await isAuthenticated())) redirect("/api/auth/login");
const user = await getUser();
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-1">Dashboard</h1>
<p className="text-gray-500 mb-8">
Welcome back, {user?.given_name ?? "User"}
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{/* Visible to all authenticated users */}
<div className="rounded-lg border p-6">
<h2 className="font-semibold mb-2">Posts</h2>
<p className="text-sm text-gray-500 mb-4">Manage your content</p>
<PermissionGate permission="posts:create">
<a
href="/dashboard/posts/new"
className="text-sm font-medium text-blue-600 hover:underline"
>
+ New post
</a>
</PermissionGate>
</div>
{/* Only users with users:manage see this card */}
<PermissionGate permission="users:manage">
<div className="rounded-lg border p-6 bg-amber-50">
<h2 className="font-semibold mb-2">Users</h2>
<p className="text-sm text-gray-500 mb-4">
Manage team members and access
</p>
<a
href="/admin/users"
className="text-sm font-medium text-amber-700 hover:underline"
>
View all users
</a>
</div>
</PermissionGate>
{/* Only users with posts:delete see this card */}
<PermissionGate permission="posts:delete">
<div className="rounded-lg border border-red-200 p-6 bg-red-50">
<h2 className="font-semibold text-red-800 mb-2">
Content Management
</h2>
<p className="text-sm text-red-600 mb-4">
Delete and bulk-manage content
</p>
<a
href="/admin/content"
className="text-sm font-medium text-red-700 hover:underline"
>
Manage content
</a>
</div>
</PermissionGate>
</div>
</main>
);
}
Note: The isGranted property is what you check — not just whether canCreatePosts is truthy. getPermission() always returns an object; isGranted is false when the user does not have the permission rather than returning null. This makes the check explicit and safe.
Step #8: Enforce Permissions in Route Handlers
Client-side permission checks are for UX — they hide controls that would fail. Server-side checks in API routes are the real security gate. You need both.
Create a protected API route at app/api/protected/posts/route.ts:
// app/api/protected/posts/route.ts
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const { isAuthenticated, getPermission, getUser } =
getKindeServerSession();
// Step 1: Check authentication
if (!(await isAuthenticated())) {
return NextResponse.json(
{ error: "Unauthenticated. Please sign in." },
{ status: 401 }
);
}
// Step 2: Check the specific permission required for this action
const canCreatePosts = await getPermission("posts:create");
if (!canCreatePosts?.isGranted) {
return NextResponse.json(
{
error: "Forbidden",
message: "You do not have permission to create posts.",
},
{ status: 403 }
);
}
// Step 3: Parse the request
const body = await request.json();
const { title, content } = body;
if (!title || !content) {
return NextResponse.json(
{ error: "Title and content are required." },
{ status: 400 }
);
}
// Step 4: Execute the action — safe to proceed
const user = await getUser();
// Replace this with your actual database logic
const post = {
id: crypto.randomUUID(),
title,
content,
authorId: user?.id,
createdAt: new Date().toISOString(),
};
return NextResponse.json({ post }, { status: 201 });
}
export async function DELETE(request: NextRequest) {
const { isAuthenticated, getPermission } = getKindeServerSession();
if (!(await isAuthenticated())) {
return NextResponse.json(
{ error: "Unauthenticated." },
{ status: 401 }
);
}
// Delete requires a more privileged permission than create
const canDeletePosts = await getPermission("posts:delete");
if (!canDeletePosts?.isGranted) {
return NextResponse.json(
{
error: "Forbidden",
message: "You do not have permission to delete posts.",
},
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const postId = searchParams.get("id");
if (!postId) {
return NextResponse.json(
{ error: "Post ID is required." },
{ status: 400 }
);
}
// Your delete logic here
return NextResponse.json({ deleted: true, postId });
}
Step #9: Build a Reusable Permission Guard Component
Checking permissions inline in every component gets repetitive. A PermissionGate component keeps the pattern clean and consistent:
// components/PermissionGate.tsx
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
interface PermissionGateProps {
permission: string;
children: React.ReactNode;
// Optional: what to show if the user lacks the permission
fallback?: React.ReactNode;
}
export async function PermissionGate({
permission,
children,
fallback = null,
}: PermissionGateProps) {
const { getPermission } = getKindeServerSession();
const result = await getPermission(permission);
if (!result?.isGranted) {
return <>{fallback}</>;
}
return <>{children}</>;
}
Now any Server Component in your app can use it cleanly:
// app/dashboard/posts/page.tsx
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";
import { PermissionGate } from "@/components/PermissionGate";
export default async function PostsPage() {
const { isAuthenticated } = getKindeServerSession();
if (!(await isAuthenticated())) {
redirect("/api/auth/login");
}
return (
<main className="p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Posts</h1>
{/* Only users with posts:create see this button */}
<PermissionGate permission="posts:create">
<a
href="/dashboard/posts/new"
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
New post
</a>
</PermissionGate>
</div>
{/* Post list visible to everyone */}
<PostList />
{/* Bulk delete only for users with posts:delete */}
<PermissionGate
permission="posts:delete"
fallback={
<p className="mt-8 text-sm text-gray-400">
You have view-only access to this section.
</p>
}
>
<BulkDeleteControls />
</PermissionGate>
</main>
);
}
// Placeholder components — replace with your real implementations
function PostList() {
return <div className="space-y-4">{/* Post items */}</div>;
}
function BulkDeleteControls() {
return (
<div className="mt-8 rounded-lg border border-red-200 p-4">
<p className="text-sm font-medium text-red-700">Bulk actions</p>
</div>
);
}
Wonderful! PermissionGate works as a clean wrapper everywhere. No repeated getPermission calls spread across components — just one consistent primitive.
Step #10: Add Sign In and Sign Out
With protection and permissions in place, add the authentication links. Kinde provides LoginLink, RegisterLink, and LogoutLink components that generate the correct Kinde auth URLs:
// app/page.tsx
import {
LoginLink,
RegisterLink,
} from "@kinde-oss/kinde-auth-nextjs/components";
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";
export default async function Home() {
const { isAuthenticated } = getKindeServerSession();
// If already authenticated, send to dashboard
if (await isAuthenticated()) {
redirect("/dashboard");
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold mb-4">My RBAC App</h1>
<p className="text-gray-500 mb-8 text-center max-w-md">
Sign in to access your dashboard. Your permissions are
automatically loaded from your role.
</p>
<div className="flex gap-4">
<LoginLink className="rounded-md bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700">
Sign in
</LoginLink>
<RegisterLink className="rounded-md border border-gray-300 px-6 py-2 text-sm font-medium hover:bg-gray-50">
Create account
</RegisterLink>
</div>
</main>
);
}
For sign out, create a simple server action or use the LogoutLink component anywhere in your authenticated layout:
// app/dashboard/layout.tsx
import { LogoutLink } from "@kinde-oss/kinde-auth-nextjs/components";
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, getUser, getRoles } = getKindeServerSession();
if (!(await isAuthenticated())) {
redirect("/api/auth/login");
}
const user = await getUser();
const roles = await getRoles();
// Show the first role for display purposes
// Your app might show this differently
const displayRole = roles?.[0]?.name ?? "User";
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation bar */}
<nav className="border-b bg-white px-6 py-4">
<div className="flex items-center justify-between">
<span className="font-semibold">My RBAC App</span>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{user?.email}
</span>
{/* Role badge — for display only, never for access control */}
<span className="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800">
{displayRole}
</span>
<LogoutLink className="text-sm text-gray-600 hover:text-gray-900">
Sign out
</LogoutLink>
</div>
</div>
</nav>
<main>{children}</main>
</div>
);
}
Note: The role badge in the nav is display-only. It tells the user what their role is, which is good UX. But your access decisions in code never read this display value — they always call getPermission("permission:key").
Putting It All Together
Here is the complete file structure for the RBAC setup you have just built:
my-rbac-app/
├── app/
│ ├── api/
│ │ └── auth/
│ │ └── [kindeAuth]/
│ │ └── route.ts ← Kinde auth handler
│ │ └── protected/
│ │ └── posts/
│ │ └── route.ts ← Permission-checked API route
│ ├── dashboard/
│ │ ├── layout.tsx ← Nav with user/role display
│ │ ├── page.tsx ← Permission-based UI
│ │ └── posts/
│ │ └── page.tsx ← Uses PermissionGate
│ ├── admin/
│ │ └── users/
│ │ └── page.tsx ← Admin-only page
│ ├── layout.tsx ← KindeProvider wrapper
│ └── page.tsx ← Login / register landing
├── components/
│ └── PermissionGate.tsx ← Reusable permission guard
├── proxy.ts ← Next.js 16 route protection
└── .env.local ← Kinde credentials
The flow for every protected request:
Step 1: proxy.ts intercepts the request. If the route matches the matcher and the user is unauthenticated, Kinde redirects to login. Authenticated users pass through.
Step 2: The Server Component or Route Handler calls getKindeServerSession() and reads permissions from the token. No database query.
Step 3: getPermission("permission:key") returns { isGranted: boolean }. If false, the component renders the fallback or the route handler returns 403.
Step 4: The user sees exactly what their role allows them to see. The role definition lives in Kinde — change a role's permissions in the dashboard and every user with that role sees the updated access on their next session.
A Note on use cache in Next.js 16
Next.js 16 introduces use cache as a new opt-in caching primitive. You can add it to pages, components, and functions to cache their output.
Do not add use cache to any page or component that renders based on the authenticated user's permissions. Cached outputs are stored and reused across requests — if you cache a dashboard page that shows admin controls for one user, the next user to hit that route may see the cached admin UI regardless of their actual permissions.
Keep permission-checked components and pages entirely outside use cache boundaries. If you need to cache parts of a permission-protected page, use React's Suspense to split the cacheable sections from the permission-gated sections:
// ✓ Correct: cache the public section, NOT the permission-gated section
export default async function PostsPage() {
const { isAuthenticated } = getKindeServerSession();
if (!(await isAuthenticated())) redirect("/api/auth/login");
return (
<main>
{/* This section is the same for all authenticated users — safe to cache */}
<Suspense fallback={<p>Loading posts...</p>}>
<PublicPostList /> {/* Has "use cache" inside */}
</Suspense>
{/* This section is specific to the user's permissions — never cache */}
<PermissionGate permission="posts:delete">
<BulkDeleteControls />
</PermissionGate>
</main>
);
}
Testing Your RBAC Implementation
Verify your setup by testing these scenarios:
Test 1 — Unauthenticated access. Open an incognito window and navigate to /dashboard. You should be redirected to the Kinde login page.
Test 2 — Role-based UI. Log in as your admin user. Confirm the Users card and bulk delete controls are visible. Navigate to Users in Kinde, change your role to editor, and refresh. The Users card and delete controls should disappear.
Test 3 — API enforcement. Using a REST client like Postman or curl, send a DELETE request to /api/protected/posts?id=123 without an authenticated session. You should receive a 401. Log in with an editor-role user and try again — you should receive a 403, because editors have posts:create and posts:update but not posts:delete.
Test 4 — Permission updates. In Kinde, add the users:view permission to the editor role. Refresh the page while logged in as an editor — the Users section should now appear, with no code change on your part.
Conclusion
In this article, you built a complete RBAC system for a Next.js 16.2 application using Kinde. You used proxy.ts — the new Next.js 16 network boundary file — for route protection, getKindeServerSession() for server-side permission reads, getPermission() for granular access checks, and a reusable PermissionGate component for clean conditional rendering.
The most important thing you built is not the code — it is the separation. Roles and permissions live in Kinde. Code checks permissions. The two halves are cleanly decoupled. When your product evolves and you need to add a new role or change what an editor can do, you change a configuration in the Kinde dashboard. Your Next.js application does not need to be redeployed.
Kinde is free for up to 10,500 monthly active users, no credit card required. Get started at kinde.com.








Top comments (0)