Protected Routes & Middleware

Configure route protection with Clerk middleware, manage public routes, and protect API endpoints

Kit protects routes at the middleware level — before your page code even runs. Every incoming request passes through middleware.ts, which determines whether the user is allowed to access the route. Unauthenticated users trying to access protected routes are automatically redirected to the sign-in page.
This page explains how the middleware works, which routes are public, and how to add your own.

How Route Protection Works

Next.js middleware runs on the Edge Runtime before every request reaches a page or API route. Kit's middleware uses Clerk's createRouteMatcher() to classify routes as public or protected:
  • Public routes are accessible without authentication. Auth state is still available (so you can show a "Sign in" or "Go to dashboard" button), but it is not required.
  • Protected routes require a valid Clerk session. If the user is not signed in, Clerk automatically redirects them to the sign-in page configured in NEXT_PUBLIC_CLERK_SIGN_IN_URL.

Public Routes

These routes are accessible without authentication:
PatternPages
/Home page
/about(.*)About pages
/blog(.*)Blog pages (if enabled)
/contact(.*)Contact page
/login(.*)Sign-in page
/register(.*)Sign-up page
/pricing(.*)Pricing page
/payment/(.*)Payment processing
/logout(.*)Sign-out page
/api/health(.*)Health check endpoint
/api/pricing(.*)Pricing data API
/api/contact(.*)Contact form API
/api/newsletter(.*)Newsletter signup API
/api/webhooks/lemonsqueezy(.*)Payment webhooks
/api/webhooks/resend(.*)Email webhooks
/api/webhooks/clerk(.*)Auth webhooks
/robots.txtSEO robots file
/sitemap.xmlSEO sitemap

Protected Routes

Everything not in the public list is protected. This includes:
  • /dashboard and all sub-routes (/dashboard/billing, /dashboard/chat-llm, etc.)
  • /api/* endpoints not explicitly listed as public
  • Any new pages you add under the (dashboard) route group

The Middleware

The middleware file handles multiple concerns beyond authentication. Here is the complete initialization and route matching:
src/middleware.ts — Clerk initialization and route matching
async function initClerkMiddleware() {
  if (!clerkMiddlewareInstance) {
    try {
      const { clerkMiddleware, createRouteMatcher } = await import(
        '@clerk/nextjs/server'
      )

      // Define public routes that don't require authentication
      const isPublicRoute = createRouteMatcher([
        '/login(.*)',
        '/register(.*)',
        '/privacy(.*)',
        '/terms(.*)',
        '/imprint(.*)',
        '/payment/(.*)',
        '/email-preview(.*)',
        '/logout(.*)',
        '/api/health(.*)',
        '/api/pricing(.*)',
        '/api/webhooks/lemonsqueezy(.*)',
        '/api/webhooks/resend(.*)',
        '/api/webhooks/clerk(.*)',
        '/robots.txt',
        '/sitemap.xml',
      ])

      clerkMiddlewareInstance = clerkMiddleware(
        async (auth, req: NextRequest) => {
          // Public routes: Auth state is available but not required
          // This allows pages to check userId for optional redirects
          if (isPublicRoute(req)) {
            // Don't protect, but auth state is still set by clerkMiddleware
            return
          }

          // Protect all other routes (dashboard, API endpoints, etc.)
          // v6: auth.protect() is now a property, not a method call
          await auth.protect()
        }
      )
    } catch (error) {
      console.warn('Failed to initialize Clerk middleware:', error)
      clerkMiddlewareInstance = (_request: NextRequest) => NextResponse.next()
    }
  }
  return clerkMiddlewareInstance
}
And here is the main middleware function that orchestrates the full request pipeline:
src/middleware.ts — Main middleware function
// Main middleware function with security enhancements
async function middleware(request: NextRequest) {
  // Always bypass in test/CI environments
  if (isTestEnvironment()) {
    return NextResponse.next()
  }

  try {
    // 1. Handle CORS preflight requests first
    const corsResponse = corsMiddleware(request)
    if (corsResponse) {
      // Preflight request handled, return immediately
      return corsResponse
    }

Dynamic Clerk Loading

The middleware uses dynamic imports to load Clerk's SDK. This is a deliberate pattern:
typescript
let clerkMiddlewareInstance: any = null

async function initClerkMiddleware() {
  if (!clerkMiddlewareInstance) {
    const { clerkMiddleware, createRouteMatcher } = await import('@clerk/nextjs/server')
    // ... configure and cache
  }
  return clerkMiddlewareInstance
}
Why dynamic imports instead of top-level imports?
  1. Test environments — When NEXT_PUBLIC_CLERK_ENABLED=false, the middleware bypasses Clerk entirely. Dynamic imports prevent the Clerk SDK from being loaded at all.
  2. Graceful fallback — If the Clerk import fails (missing dependency, configuration error), the middleware falls back to NextResponse.next() instead of crashing.
  3. Lazy initialization — The Clerk middleware is created once and cached. Subsequent requests reuse the same instance.

Route Matching with createRouteMatcher

Clerk's createRouteMatcher() takes an array of route patterns and returns a function that checks if a request matches. Patterns use Clerk's path matching syntax:
  • /about(.*) matches /about, /about/team, /about/anything
  • /api/webhooks/clerk(.*) matches the webhook endpoint and any sub-paths
  • / matches only the exact root path
The matcher returns true for public routes. All other routes are passed to auth.protect(), which throws a redirect to the sign-in page if the user is not authenticated.

The Middleware Stack

The middleware executes in a specific order. Each stage must complete before the next begins:
1

Test Mode Check

If isTestEnvironment() returns true, the middleware returns NextResponse.next() immediately — bypassing all subsequent checks. This ensures tests run without Clerk overhead.
2

Blog Feature Flag

If the blog is disabled (NEXT_PUBLIC_ENABLE_BLOG=false) and the request targets /blog/*, the middleware redirects to the home page.
3

CORS Preflight

CORS preflight requests (OPTIONS method) are handled immediately and returned. No auth checks needed for preflight.
4

Clerk Authentication

The Clerk middleware runs, setting auth state on the request. For public routes, auth state is available but optional. For protected routes, auth.protect() enforces authentication.
5

Security Headers

After Clerk processes the request, security headers (CSP, HSTS, X-Frame-Options) are applied to the response.
6

CORS Headers

Finally, CORS headers are added to the response for non-preflight requests.

Adding New Routes

Adding a Public Route

To make a new route publicly accessible:
1

Add the pattern to createRouteMatcher

Open apps/boilerplate/src/middleware.ts and add your route pattern to the createRouteMatcher array:
typescript
const isPublicRoute = createRouteMatcher([
  '/',
  '/about(.*)',
  // ... existing routes
  '/your-new-page(.*)',  // Add your route here
])
2

Create the page

Create your page file. Public pages typically go in the (marketing) route group:
apps/boilerplate/src/app/(marketing)/your-new-page/page.tsx
3

Test it

Visit the page in your browser while signed out. It should load without redirecting to the login page. Visit it while signed in — auth state should be available via useConditionalAuth() if you need it.

Adding a Protected API Route

Protected API routes require authentication. Since they are protected by default, you only need to verify the user inside the handler:
typescript
// src/app/api/my-endpoint/route.ts
import { getServerAuth } from '@/lib/auth/server-helpers'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const { userId } = await getServerAuth()

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

  // Your protected logic here
  return NextResponse.json({ message: 'Hello', userId })
}
The middleware ensures the user has a valid session before the handler runs. The getServerAuth() check is an additional safety layer — if middleware is misconfigured, the API route still rejects unauthenticated requests.

Server Component Authentication

In the dashboard, Server Components need to know who the current user is for data fetching and authorization. Kit handles this in the dashboard layout.

Dashboard Layout

The dashboard layout is a Server Component that resolves the current user and provides their database ID to all child components:
src/app/(dashboard)/layout.tsx
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const layoutStart = performance.now()
  console.log('[PERF] Dashboard Layout start')

  let userId: string | null = null

  // Only attempt Clerk calls if not in test/CI environment
  if (!shouldBypassClerk()) {
    try {
      const clerkImportStart = performance.now()
      // Dynamic import to avoid bundling Clerk in test environments
      const { currentUser } = await import('@clerk/nextjs/server')
      console.log(
        '[PERF] Clerk import took:',
        (performance.now() - clerkImportStart).toFixed(2),
        'ms'
      )

      const clerkUserStart = performance.now()
      const user = await currentUser()
      console.log(
        '[PERF] Clerk currentUser() took:',
        (performance.now() - clerkUserStart).toFixed(2),
        'ms'
      )

      // Get database user ID for prefetching
      if (user?.id) {
        const dbUserStart = performance.now()
        const dbUser = await userRepository.findByClerkId(user.id)
        console.log(
          '[PERF] DB user lookup took:',
          (performance.now() - dbUserStart).toFixed(2),
          'ms'
        )
        userId = dbUser?.id || null
      }
    } catch (error) {
      // Only log in development to keep build output clean
      if (process.env.NODE_ENV === 'development') {
        console.warn('Could not fetch current user, using demo mode:', error)
      }
    }
  } else {
    // Test mode: Use test user
    const testUserStart = performance.now()
    const dbUser = await userRepository.findByClerkId(testUser.id)
    console.log(
      '[PERF] Test user lookup took:',
      (performance.now() - testUserStart).toFixed(2),
      'ms'
    )
    userId = dbUser?.id || null
  }
The flow works like this:
  1. Check environment — If Clerk is bypassed (test mode), use the seeded test user.
  2. Get Clerk user — Dynamic import of currentUser() to avoid bundling Clerk in tests.
  3. Resolve database user — Look up the database record by clerkId using the repository pattern.
  4. Prefetch data — Use TanStack Query to prefetch dashboard data (credits, billing) on the server for instant page loads.
  5. Provide context — Wrap children with DbUserProvider so any component can access the database user ID via useDbUser().
This pattern means your dashboard pages never need to fetch the current user themselves — the layout has already resolved it.

API Route Protection

Kit provides two layers of API protection:
Layer 1: Middleware — Rejects unauthenticated requests before they reach the handler. This is automatic for all routes not in the public list.
Layer 2: Handler-Level Auth — Use getServerAuth() inside the handler for an additional check. This protects against middleware misconfiguration and provides the userId for data queries.
Layer 3: Rate Limiting — Wrap handlers with withRateLimit() to add per-user request limits. This combines with auth to prevent both unauthenticated and excessive access.
typescript
import { withRateLimit } from '@/lib/security/rate-limit-middleware'

export const POST = withRateLimit('api', async (request: NextRequest) => {
  // Auth is already verified by middleware + rate limiter
  // Your handler logic here
})
Rate Limit Categories:
CategoryUser LimitIP LimitUse For
api100 req/hour200 req/hourGeneral API endpoints
upload10 req/hour20 req/hourFile upload endpoints
email5 req/hour10 req/hourEmail sending endpoints
contact3 req/hourContact form (IP-only)
payments20 req/hourPayment endpoints
webhooks100 req/hourExternal webhook receivers
AI endpoints have a separate tier-based rate limiter with monthly quotas (Free: 500/month, Basic: 1,500, Pro: 5,000, Enterprise: 15,000) plus a global burst limit of 10 requests per 10 seconds. See Caching & Redis for the full rate limiting architecture.