Protect Routes

Learn how to protect routes with middleware, server-side checks, and role-based access control

Learn how to protect routes in your Plainform application using Clerk middleware and authentication checks.

Goal

By the end of this recipe, you'll have secured your application routes with authentication and role-based access control.

Important: By default, clerkMiddleware() does not protect any routes. All routes are public unless you explicitly add protection with auth.protect().

Protection Patterns

Protect All Routes Except Public

Protect all routes by default, make specific routes public:

proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/forgot-password(.*)',
  '/sso-callback(.*)',
  '/blog(.*)',
  '/docs(.*)',
  '/api/webhooks(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
};

Protect Specific Routes Only

Only protect specific routes (e.g., dashboard):

proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',
  '/profile(.*)',
  '/settings(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect();
  }
});

Role-Based Protection

Protect routes based on user roles:

proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isPublicRoute = createRouteMatcher(['/', '/sign-in(.*)', '/sign-up(.*)']);
const isAdminRoute = createRouteMatcher(['/admin(.*)']);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }

  if (isAdminRoute(req)) {
    await auth.protect((has) => {
      return has({ role: 'org:admin' });
    });
  }
});

Pattern matching:

  • /pricing - Exact match
  • /blog(.*) - Matches /blog and all sub-routes
  • /api/public(.*) - Matches all routes starting with /api/public/

Server-Side Protection

Add checks in Server Components for defense in depth:

app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const { userId } = await auth();

  if (!userId) {
    redirect('/sign-in');
  }

  return <div>Dashboard</div>;
}

Role-based protection:

app/admin/page.tsx
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';

export default async function AdminPage() {
  const { userId, has } = await auth();

  if (!userId || !has({ role: 'org:admin' })) {
    redirect('/');
  }

  return <div>Admin Dashboard</div>;
}

API Route Protection

app/api/user/profile/route.ts
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

export async function GET() {
  const { userId } = await auth();

  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const profile = await getUserProfile(userId);
  return NextResponse.json(profile);
}

Role-based API protection:

app/api/admin/users/route.ts
export async function GET() {
  const { userId, has } = await auth();

  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  if (!has({ role: 'org:admin' })) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const users = await getAllUsers();
  return NextResponse.json(users);
}

Client-Side Protection

Conditionally render UI based on authentication:

components/ProtectedButton.tsx
'use client';

import { useAuth } from '@clerk/nextjs';

export function ProtectedButton() {
  const { isSignedIn, isLoaded, has } = useAuth();

  if (!isLoaded) return <div>Loading...</div>;
  if (!isSignedIn) return null;

  return (
    <>
      <button>Protected Action</button>
      {has({ role: 'org:admin' }) && <button>Admin Action</button>}
    </>
  );
}

Common Issues

Infinite Redirect Loop

  • Ensure sign-in/sign-up routes are in isPublicRoute
  • Verify sso-callback route is public

Protected Route Still Accessible

  • Check that auth.protect() is called for the route
  • Clear browser cache and restart dev server

Middleware Not Running

  • Verify proxy.ts is in project root
  • Check config.matcher includes your routes

Role Check Failing

  • Verify user has correct role in Clerk Dashboard
  • Check role name matches exactly (case-sensitive)

Next Steps

How is this guide ?

Last updated on

On this page