Production caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
- Views
- Likes
shubhra.dev
Loading...
Next.js 16 role-based auth in one file. RBAC, auto token refresh, cross-tab sync, proxy-based route protection. Prevents common hydration errors, zero additional runtime dependencies.
All sales are final · No refunds on digital products
// ═══════════════════════════════════════════════════════
// Auth Guard: Role-Based Route Protection
// ═══════════════════════════════════════════════════════
// THE PROBLEM: Auth in Next.js breaks in ways that cost you days
// ✗ localStorage on server -> hydration mismatch, React throws
// ✗ Role check before tokens load -> infinite redirect loop
// ✗ middleware.ts on Edge Runtime -> JWT libraries crash
// (jsonwebtoken needs Node.js crypto, not available at the edge)
// ✗ Token expires mid-session -> user gets logged out mid-action
// ✗ Logout in Tab A -> Tab B still shows the dashboard
// WHAT YOU GET (5 exports, 1 file, zero additional runtime deps):
// ✅ AuthProvider - tokens, refresh, cross-tab sync
// ✅ RouteGuard - declarative roles + permissions
// ✅ useAuth - hook for any component
// ✅ withAuth - HOC for page-level guards
// ✅ AuthError - typed errors with error codes
// ✅ proxy.ts - Node.js runtime JWT template
// PROTECT A ROUTE IN 3 LINES:
<AuthProvider apiUrl="/api/auth">
<RouteGuard allowedRoles={["admin"]} requiredPermissions={["users:write"]}>
<AdminDashboard />
</RouteGuard>
</AuthProvider>
// --- TypeScript interfaces ----------------------------------------
type UserRole = "admin" | "user" | "moderator" | "guest" | (string & {});
type Permission = string;
interface User {
id: string;
email: string;
role: UserRole;
permissions: Permission[];
name?: string;
avatar?: string;
metadata?: Record<string, unknown>;
}
interface AuthContextValue {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
hasRole: (roles: UserRole[]) => boolean;
hasPermission: (permission: Permission) => boolean;
hasAllPermissions: (permissions: Permission[]) => boolean;
hasAnyPermission: (permissions: Permission[]) => boolean;
login: (email: string, password: string, remember?: boolean) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
updateUser: (updates: Partial<User>) => void;
getAccessToken: () => string | null;
}
interface RouteGuardProps {
children: ReactNode;
allowedRoles?: UserRole[];
requiredPermissions?: Permission[];
permissionMode?: "all" | "any";
fallbackUrl?: string;
unauthorizedUrl?: string;
loadingComponent?: ReactNode;
unauthorizedComponent?: ReactNode;
}
💡 This is a preview. Purchase to unlock the complete implementation.
Purchase to unlock the complete production-ready implementation with full TypeScript support.
Love this snippet?
Share it with your friends and colleagues
Stop building from scratch. Get production-ready code with full documentation and edge-case handling built in.
Production caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
A dev-only toolkit that instruments your 'use cache' functions with zero production cost. Catch cache misses, dynamic holes, missing tags, and deprecated invalidation calls. All output goes straight to your terminal.
Updated June 2026 for Next.js 16. Compatible with Next.js 13-16.
Next.js auth has a few specific failure modes that only surface in production, usually after you've already shipped:
Hydration mismatches. localStorage.getItem() returns null on the server and a real token on the client. React throws. You spend two hours debugging a flicker that only happens on first load.
Redirect loops. Your RouteGuard runs before auth state has loaded. It sees no user, redirects to /login. Login sees a valid token, redirects back. Infinite loop. Users get stuck.
Edge Runtime crash. Older patterns using middleware.ts often relied on the Edge Runtime, where libraries like jsonwebtoken fail due to missing Node.js APIs. In Next.js 16, server-side route protection is better handled using Node.js runtime patterns (like proxy.ts) instead of Edge middleware for JWT verification.
Token expiry mid-session. User is filling out a form when their access token expires. The next API call returns 401. Nothing refreshes it. They lose their work and get a confusing error.
Cross-tab state. User logs out in Tab A. Tab B still shows the dashboard with their name in the header. They think something is wrong with your app.
Clerk costs $25/month for basic role-based access. NextAuth v5 is powerful, but often requires significant setup and custom session handling for RBAC.
Ask any AI for "Next.js role-based auth" and you get a useEffect + useRouter wrapper around localStorage:
typeof window !== "undefined", throws on server render.isLoading settles. Redirects before tokens have been read. Users get looped.StorageEvent listener. Logout in one tab is invisible to all others.remember=false and no expiry when remember=true. Backwards.React.ComponentType without React import. The HOC won't compile. TypeScript throws on the first build.memoryStore inside useMemo. React 19 strict mode double-invokes useMemo. New Map on second call, first Map's data silently dropped.setTimeout(0) instead of direct state. Wraps every setState in a setTimeout after an await inside useEffect. Causes an extra loading flash on fast connections.Every one of these ships to production. I wrote this to fix exactly that.
| Feature | Free Hook | This Hook |
|---|---|---|
| AuthProvider context | ✅ | ✅ |
| useAuth hook | ✅ | ✅ |
| Basic route protection | ✅ | ✅ |
| TypeScript strict mode | ✅ | ✅ |
| Role-based access control | ❌ | ✅ |
| Permission checks (all / any) | ❌ | ✅ |
| Automatic token refresh | ❌ | ✅ |
| Cross-tab session sync | ❌ | ✅ |
| Remember me (30-day vs session-only) | ❌ | ✅ |
| Configurable storage | ❌ | ✅ |
| Session polling | ❌ | ✅ |
| withAuth HOC | ❌ | ✅ |
| Proxy template (Node.js runtime) | ❌ | ✅ |
| onLogin / onLogout callbacks | ❌ | ✅ |
| onSessionExpire callback | ❌ | ✅ |
| updateUser / getAccessToken | ❌ | ✅ |
| AuthError class with error codes | ❌ | ✅ |
| Prevents common hydration errors | ❌ | ✅ |
| React 19 strict mode compliant | ❌ | ✅ |
| JSDoc on every export | ❌ | ✅ |
| Commercial license | ❌ | ✅ |
remember=true sets a 30-day cookie; remember=false sets a session cookie the browser clears on close.initDoneRef prevents double-init, no setTimeout tricks.typeof window and typeof document checks.AuthError with machine-readable codes for clean error handling in login forms.The root layout in Next.js App Router is a Server Component by default. Because AuthProvider uses React hooks, it needs to live in a Client Component. The correct pattern is a dedicated wrapper so your root layout stays a Server Component:
// app/providers.tsx <-- create this file
"use client";
import { useRouter } from "next/navigation";
import { AuthProvider } from "@/lib/auth-guard";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
const router = useRouter();
return (
<AuthProvider
apiUrl="/api/auth"
storage="localStorage"
onSessionExpire={() => router.push("/login?reason=expired")
// app/layout.tsx <-- root layout stays a Server Component
import { Providers } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// app/admin/page.tsx
import { RouteGuard } from "@/lib/auth-guard";
export default function AdminPage() {
return (
<RouteGuard
allowedRoles={["admin"]}
requiredPermissions={["users:write"]}
permissionMode="all"
>
<AdminDashboard />
</RouteGuard>
);
}// app/dashboard/page.tsx
import { withAuth } from "@/lib/auth-guard";
function Dashboard() {
return <DashboardContent />;
}
export default withAuth(Dashboard, {
allowedRoles: ["admin", "user", "moderator"],
});function Navbar() {
const { user, hasRole, hasPermission, logout } = useAuth();
return (
<nav>
<span>Hi, {user?.name}</span>
{hasRole(["admin"]) && <Link href="/admin">Admin</Link>}
{hasPermission("billing:manage") && <Link href="/billing">Billing</Link>}
<button onClick={logout}>Logout</
async function handleSubmit(email: string, password: string) {
try {
await login(email, password, rememberMe);
router.push("/dashboard");
} catch (err) {
if (err instanceof AuthError) {
if (err.code === "INVALID_CREDENTIALS")
setError("Wrong email or password.");
else if (err.code === "ACCOUNT_LOCKED")
setError("Account locked. Contact support.");
else
<RouteGuard
allowedRoles={["admin"]}
unauthorizedComponent={
<div className="p-8 text-center">
<h2>Admin access required</h2>
<p>You don't have permission to view this page.</p>
</div>
}
>
<AdminSettings />
</RouteGuard>| Prop | Type | Default | Description |
|---|---|---|---|
apiUrl | string | "/api/auth" | Your auth API base URL |
storage | "localStorage" | "cookie" | "memory" | "localStorage" | Token storage method |
sessionCheckInterval | number | 60000 | How often to validate tokens in ms. 0 to disable |
onSessionExpire | () => void | Called when session expires or refresh fails | |
onLogin | (user: User) => void | Called after successful login | |
onLogout | () => void | Called after logout completes |
| Prop | Type | Default | Description |
|---|---|---|---|
allowedRoles | UserRole[] | [] | Roles that may access. Empty = any authenticated user |
requiredPermissions | Permission[] | [] | Permissions required |
permissionMode | "all" | "any" | "all" | Require all or any of the listed permissions |
fallbackUrl | string | "/login" | Redirect for unauthenticated users |
unauthorizedUrl | string | "/unauthorized" | Redirect for authenticated but unauthorized users |
loadingComponent | ReactNode | Spinner | UI shown while auth state loads |
unauthorizedComponent | ReactNode | Renders inline when unauthorized instead of redirecting |
| Field | Type | Description |
|---|---|---|
user | User | null | Currently authenticated user |
isLoading | boolean | True while the initial auth check runs |
isAuthenticated | boolean | True once a valid session exists |
hasRole(roles) | (roles) => boolean | True if user has at least one of the roles |
hasPermission(p) | (p) => boolean | True if user has this specific permission |
hasAllPermissions(ps) | (ps) => boolean | True if user has every permission in the array |
hasAnyPermission(ps) | (ps) => boolean | True if user has at least one permission |
login(email, pw, r?) | Promise<void> | Log in. Throws AuthError on failure |
logout() | Promise<void> | Log out. Clears all tokens |
refreshSession() | Promise<void> | Manually refresh the access token |
updateUser(updates) | (updates) => void | Patch the cached user object |
getAccessToken() | () => string | null | Get the current access token |
Auth Guard expects your login and refresh endpoints to return this shape:
// POST /api/auth/login
// POST /api/auth/refresh
{
user: {
id: string;
email: string;
role: string;
permissions: string[];
name?: string;
};
tokens: {
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix ms timestamp
};
}
// POST /api/auth/logout
// 200 OK - body ignoredEvery access to window, document, or localStorage is wrapped in a typeof window !== "undefined" check. The hook reads nothing during server render. When the component mounts on the client, it reads stored tokens once, correctly, with no double-read. This is what prevents hydration mismatches.
Cookie security and XSS. All three storage options (localStorage, cookie, memory) are accessible to JavaScript running in the same origin. None of them are immune to XSS. If an attacker can run arbitrary JS on your page, they can read tokens regardless of which storage type you chose. The real XSS defence is Content Security Policy, input sanitisation, and keeping your dependency tree clean, not your token storage choice.
That said, there are meaningful differences. cookie storage with SameSite=Strict significantly reduces CSRF risk by preventing cross-site requests from including cookies by default. localStorage has no built-in CSRF protection but is simpler to work with in SPAs. Both are readable by JS in the same origin. Neither uses httpOnly, because httpOnly can only be set server-side. If your threat model specifically requires httpOnly cookies, you need server-side session management: set the cookie from your API response, never from client code.
RouteGuard holds a decision state that starts as "loading". It only evaluates roles and permissions inside a useEffect that watches isLoading. As long as isLoading is true, the guard renders the loading component, not a redirect. By the time isLoading flips to false, tokens have been read and auth state is settled. The redirect never fires on stale state.
On mount, the hook reads the stored tokens.expiresAt timestamp. If the token has less than 5 minutes remaining (REFRESH_THRESHOLD), it calls refreshSession() immediately before any component renders, so the user always starts with a valid token. If the token is still fresh, the stored user is restored to state with no network call.
While the user is active, a configurable setInterval checks the stored token on each tick. It uses the same 5-minute threshold as init: if tokens.expiresAt - Date.now() < REFRESH_THRESHOLD, it refreshes proactively. The token gets replaced before it ever actually expires, so the user never hits a 401 mid-session. The interval clears automatically when the user logs out.
remember=true stores tokens with a 30-day expiry. remember=false stores tokens with no expiry attribute, which creates a session cookie the browser clears on close. This is the correct behaviour and it's the opposite of what most hand-written implementations do.
When storage="localStorage", the hook attaches a StorageEvent listener on mount. When any tab writes the token key (login, refresh) or removes it (logout), the event fires in all other tabs. Auth state updates instantly. No polling, no page reload. This mechanism is specific to localStorage - cookie storage is shared natively by the browser across tabs, and memory storage is per-tab by design.
Three things make this React 19 safe. The memory store lives at module scope, not inside useMemo, so React's double-invoke of useMemo on mount doesn't silently discard data. An initDoneRef prevents the async init from running twice. All callbacks are stored in a ref and updated via useEffect so the save pipeline always calls the latest version, the same pattern used in SWR and TanStack Query.
All lifecycle callbacks (onLogin, onLogout, onSessionExpire) are stored in a callbacksRef and refreshed on every render via useEffect. The auth pipeline always calls callbacksRef.current, which always holds the latest closure. No stale values, no missing updates.
The logout flow calls your API as a best-effort fire-and-forget. If the request fails (network down, server error, token already expired) the finally block still runs clearAuth(). The user is always logged out from the client's perspective. A failing API call never traps a user in an authenticated state they can't escape.
The proxy template included in auth-guard.ts runs on the Node.js runtime and handles server-side route protection.
It verifies the JWT, reads the role from the payload, and blocks unauthorized access before the request reaches your page. The official Next.js docs describe the proxy as best suited for optimistic checks such as permission-based redirects - a lightweight gate before the real authorization happens in your Server Components and data layer.
Important. The proxy reads tokens from request.cookies.get("auth_tokens"). This only works when <AuthProvider storage="cookie"> is set.
If you use the default storage="localStorage", the proxy will not see any tokens and will redirect every request to login.
Switch to cookie storage before enabling the proxy.
File location. Create proxy.ts in your project root, at the same level as your app folder, or inside src if you use that layout. The official docs confirm this is the correct location for Next.js 13-16.
The main auth-guard.ts file has zero additional runtime dependencies.
The optional proxy template requires:
npm install joseCopy the template from the comments at the bottom of auth-guard.ts into your project root as proxy.ts. Adjust PUBLIC_ROUTES and ROLE_ROUTES for your app.
| Framework | Support |
|---|---|
| Next.js 13-16 | ✅ |
| Remix | ⚠️ partial (no Next.js router) |
| Vite + React | ⚠️ partial (no proxy / Next.js routing) |
| React 18 & 19 | ✅ |
| TypeScript strict | ✅ |
| Browser | Support |
|---|---|
| Chrome 66+ | ✅ Full |
| Firefox 63+ | ✅ Full |
| Safari 13.1+ | ✅ Full |
| Edge 79+ | ✅ Full |
None - auth-guard.ts has zero additional runtime dependencies
react ^18 || ^19
next ^13 || ^14 || ^15 || ^16
typescript ^5 (strict mode)
The optional proxy template requires:
jose ^5 - npm install jose
I've shipped auth for the same stack four times. Every time I'd start fresh, Google the same things, and land on the same half-working patterns. Hydration error on day one. Redirect loop on day two. Token expiry bug reported by a user two weeks after launch.
The third time it happened I stopped copying Stack Overflow and actually fixed it properly. Tracked down exactly why memoryStore inside useMemo silently drops data in React 19. Fixed the remember-me logic that ships backwards in 90% of code samples I've seen. Added the initDoneRef guard that stops the double-init race condition. Got cross-tab sync working correctly.
Then on the fourth project I dropped the same file in, changed the API URL, and was done with auth in ten minutes.
That's this. It's not a framework. It's the file I wish I had on project one.