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.
Kit uses Next.js route groups to organize pages. The
(auth) group contains login and register pages, the (dashboard) group contains protected dashboard pages, and the (marketing) group contains public marketing pages. Route groups (parentheses in folder names) affect file organization but not URL paths — /dashboard maps to apps/boilerplate/src/app/(dashboard)/dashboard/page.tsx.Public Routes
These routes are accessible without authentication:
| Pattern | Pages |
|---|---|
/ | 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.txt | SEO robots file |
/sitemap.xml | SEO sitemap |
Protected Routes
Everything not in the public list is protected. This includes:
/dashboardand 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
Kit uses a deny-by-default strategy. New routes are automatically protected unless you explicitly add them to the public list. This is safer than an allow-by-default approach where forgetting to protect a route could expose sensitive data.
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?
- Test environments — When
NEXT_PUBLIC_CLERK_ENABLED=false, the middleware bypasses Clerk entirely. Dynamic imports prevent the Clerk SDK from being loaded at all. - Graceful fallback — If the Clerk import fails (missing dependency, configuration error), the middleware falls back to
NextResponse.next()instead of crashing. - 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.For rate-limited API routes, wrap your handler with
withRateLimit() from @/lib/security/rate-limit-middleware. This combines auth protection with per-user request limits. See the Security section for details.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:
- Check environment — If Clerk is bypassed (test mode), use the seeded test user.
- Get Clerk user — Dynamic import of
currentUser()to avoid bundling Clerk in tests. - Resolve database user — Look up the database record by
clerkIdusing the repository pattern. - Prefetch data — Use TanStack Query to prefetch dashboard data (credits, billing) on the server for instant page loads.
- Provide context — Wrap children with
DbUserProviderso any component can access the database user ID viauseDbUser().
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:
| Category | User Limit | IP Limit | Use For |
|---|---|---|---|
api | 100 req/hour | 200 req/hour | General API endpoints |
upload | 10 req/hour | 20 req/hour | File upload endpoints |
email | 5 req/hour | 10 req/hour | Email sending endpoints |
contact | — | 3 req/hour | Contact form (IP-only) |
payments | 20 req/hour | — | Payment endpoints |
webhooks | — | 100 req/hour | External 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.