Skip to main content

Middleware

Source: src/proxy.ts

The Next.js middleware runs before every matched request. It handles three concerns in sequence: Clerk authentication context injection, locale validation and routing (via next-intl), and root-path redirection to the org's default locale.

Matcher

// src/proxy.ts
export const config = {
matcher: [
"/((?!api|_next|_vercel|.*\\..*).*)", // all HTML routes; excludes /api/, /_next/, /_vercel/, static assets
"/api/admin/(.*)", // admin API routes (need Clerk auth context)
"/api/gforms/(.*)", // GForms API routes (need Clerk auth context)
"/([\\w-]+)?/users/(.*)", // user management routes
],
};

Static files (anything with a . in the path, e.g. favicon.ico) are excluded by the first pattern.


Middleware stack

The middleware is a single clerkMiddleware() wrapper. Inside it, requests are handled in this order:

1. Admin and GForms API routes

/api/admin/(.*) → NextResponse.next() (Clerk auth injected, no i18n)
/api/gforms/(.*) → NextResponse.next() (Clerk auth injected, no i18n)

These routes need the Clerk request context (so auth() works in route handlers) but must not go through next-intl, which would try to parse a locale from the path.

2. Root-path redirect

GET / → fetch /api/locale-default (with Host header)
→ redirect to /{defaultLocale}
→ fallback: redirect to /en (if API fails)

/api/locale-default resolves the org from the hostname, reads Organization.ropas[], and returns the entry where isDefault: true. If no ROPA is configured yet it returns "en".

3. Unsupported locale redirect

If the first path segment is a 2-letter code that is not in supportedLocales:

GET /xx/... → redirect to /en/event/language?reason=not-available&from=xx

4. Protected route auth

/:locale/e/(.*) → await auth.protect()

auth.protect() (from @clerk/nextjs/server) throws a redirect to the Clerk sign-in page if the user has no active session. All editor routes (/e/...) require authentication.

5. i18n middleware

All remaining routes pass through intlMiddleware(req) (created via createIntlMiddleware(routing)). This validates the locale segment and rewrites the request so next-intl can serve the correct messages.


Tenant resolution

The middleware itself does not resolve or inject the tenant. Tenant resolution happens inside each route handler by calling getHostnameServer() (from src/lib/url/getHostnameServer.ts), which reads request.headers.get("host") and applies this logic:

HostnameReturns
{name}.localhost:{port}{name}
localhost / 127.0.0.1"demo"
{name}.{NEXT_PUBLIC_SITE_DOMAIN}{name}
Anything else"demo"

The NEXT_PUBLIC_SITE_DOMAIN env variable (e.g. "rat.gd") is used to extract the subdomain on production. In development, subdomains of localhost map directly.


Route matchers summary

PatternClerk authi18n middlewareNotes
/api/admin/(.*)✓ (context only)Admin secret required in route handler
/api/gforms/(.*)✓ (context only)Session or email-param auth in route handler
/:locale/e/(.*)✓ (auth.protect())Authenticated editor routes
All other HTML routes✓ (context only)Public pages

Interaction with Clerk

clerkMiddleware() wraps the entire function. It makes auth() available inside route handlers across the app. The middleware itself only calls auth.protect() on protected routes — it does not inspect userId for other decisions.

Route handlers that need the current user call await auth() inside the handler (not in middleware).