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:
| Hostname | Returns |
|---|---|
{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
| Pattern | Clerk auth | i18n middleware | Notes |
|---|---|---|---|
/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).