reference
lib
paraglide
Middleware

Middleware

This guide explains how Paraglide's server middleware works, its lifecycle, and how to integrate it with any framework.

[!NOTE] The middleware is only needed for server-side rendering (SSR). If you're building a client-only SPA, skip this guide and use the runtime directly.

Quick Reference

import { paraglideMiddleware } from './paraglide/server.js'
 
paraglideMiddleware(
  request: Request,
  resolve: (args: { request: Request, locale: Locale }) => Promise<Response>,
  options?: {
    effectiveRequestUrl?: string | URL | ((request: Request) => string | URL)
    onRedirect?: (response: Response) => void
  }
): Promise<Response>

How It Works

Request → paraglideMiddleware() → Response

That's it. The middleware only handles locale detection, URL delocalization, and request isolation. It doesn't define routes, handle navigation, or intercept links - your framework's router stays in control.

Detailed Flow

  Incoming Request


┌───────────────────┐
│ 1. LOCALE         │  Evaluate strategies in order:
│    DETECTION      │  url → cookie → preferredLanguage → baseLocale
│                   │  First strategy that returns a locale wins.
└───────────────────┘


┌───────────────────┐
│ 2. REDIRECT       │  If URL strategy is used AND URL doesn't match
│    CHECK          │  the detected locale → redirect (307) to correct URL.
│                   │  Only redirects "document" requests (not API/assets).
└───────────────────┘


┌───────────────────┐
│ 3. URL            │  If URL strategy is used:
│    DELOCALIZATION │  /de/about → /about (strips locale prefix)
│                   │  Your app receives the "clean" URL.
└───────────────────┘


┌───────────────────┐
│ 4. ASYNC LOCAL    │  Wraps request in AsyncLocalStorage context.
│    STORAGE        │  getLocale() returns correct locale for THIS request.
│                   │  Prevents locale bleeding between concurrent requests.
└───────────────────┘


┌───────────────────┐
│ 5. YOUR HANDLER   │  Your resolve() callback runs here.
│    (resolve)      │  Call getLocale(), use messages, render your app.
└───────────────────┘


    Response

Parameters

request: Request

The incoming Web API Request (opens in a new tab) object.

resolve: (args) => Promise<Response>

Your request handler. Receives:

  • request: A potentially modified request with delocalized URL (e.g., /de/about/about). Use this unless your framework handles URL localization itself.
  • locale: The detected locale for this request.

options (optional)

  • effectiveRequestUrl: Sets the effective request URL Paraglide should use for route matching, URL-based locale detection, redirects, and getUrlOrigin(). Use this when request.url is an internal transport URL, such as behind a proxy or load balancer.
  • onRedirect(response): Called when middleware issues a redirect. Useful for logging or analytics.

Effective Request URL

Use effectiveRequestUrl when the browser-facing URL differs from request.url, for example behind TLS termination.

Derive it from trusted proxy or framework metadata and pass it explicitly:

const effectiveRequestUrl = new URL(request.url);
effectiveRequestUrl.protocol = "https:";
effectiveRequestUrl.host = "app.example.com";
 
return paraglideMiddleware(request, ({ request }) => resolve(request), {
  effectiveRequestUrl,
});

Paraglide uses effectiveRequestUrl for route matching, URL-based locale detection, redirects, and getUrlOrigin().

In runtimes that can clone the request, the callback request.url will also reflect that effective URL. If the runtime uses a custom Request implementation that cannot be cloned, Paraglide falls back to the original request object.

Framework Examples

SvelteKit

// src/hooks.server.ts
import { paraglideMiddleware } from "./paraglide/server.js";
import type { Handle } from "@sveltejs/kit";
 
export const handle: Handle = ({ event, resolve }) => {
  return paraglideMiddleware(event.request, ({ request, locale }) => {
    return resolve({ ...event, request });
  });
};

Next.js (App Router)

// middleware.ts
import { paraglideMiddleware } from "./paraglide/server.js";
import { NextResponse } from "next/server";
 
export async function middleware(request: Request) {
  return paraglideMiddleware(request, async ({ request, locale }) => {
    return NextResponse.next();
  });
}

Astro

// src/middleware.ts
import { paraglideMiddleware } from "./paraglide/server.js";
import { defineMiddleware } from "astro:middleware";
 
export const onRequest = defineMiddleware((context, next) => {
  return paraglideMiddleware(context.request, ({ request }) => next(request));
});

TanStack Start

[!WARNING] TanStack Router handles URL rewriting itself via rewrite.input/rewrite.output. Pass the original request to avoid redirect loops.

// server.ts
import { paraglideMiddleware } from "./paraglide/server.js";
import handler from "@tanstack/react-start/server-entry";
 
export default {
  fetch(req: Request): Promise<Response> {
    // Pass original `req` - NOT the modified `request` from callback
    return paraglideMiddleware(req, () => handler.fetch(req));
  },
};

Hono

import { Hono } from "hono";
import { paraglideMiddleware } from "./paraglide/server.js";
 
const app = new Hono();
 
app.use("*", async (c) => {
  return paraglideMiddleware(c.req.raw, async ({ request, locale }) => {
    // Your route handling here
    return c.text(`Locale: ${locale}`);
  });
});
 
export default app;

Cloudflare Workers

import { paraglideMiddleware } from "./paraglide/server.js";
 
export default {
  async fetch(request: Request): Promise<Response> {
    return paraglideMiddleware(request, async ({ request, locale }) => {
      return new Response(`Hello from ${locale}!`);
    });
  },
};

[!TIP] Cloudflare Workers supports AsyncLocalStorage through Node.js compatibility (nodejs_compat), so modern deployments can keep the default configuration.

Excluding Routes from Middleware

To skip i18n for specific routes (for example API endpoints), use routeStrategies with exclude: true in your compiler config.

This is especially useful when public pages are URL-prefixed, but private routes like /dashboard are intentionally unprefixed and should use cookie-based locale detection.

compile({
  project: "./project.inlang",
  outdir: "./src/paraglide",
  strategy: ["url", "cookie", "baseLocale"],
  routeStrategies: [
    { match: "/dashboard/:path(.*)?", strategy: ["cookie", "baseLocale"] },
    { match: "/rpc/:path(.*)?", strategy: ["cookie", "baseLocale"] },
    { match: "/api/:path(.*)?", exclude: true },
  ],
});

routeStrategies are matched in declaration order. The first match wins.

For excluded routes, Paraglide skips i18n middleware behavior (locale redirects and URL de-localization).

When to Use request vs Original Request

ScenarioUse
Framework does NOT handle URL rewritingrequest from callback
Framework handles URL rewriting (TanStack Router, custom)Original req
Browser-facing URL differs from request.urlSet effectiveRequestUrl
You're not using URL strategy at allEither works

Rule of thumb: If you see redirect loops, try passing the original request instead of the callback's request.

Redirect Behavior

The middleware only redirects when ALL of these are true:

  1. URL strategy is configured
  2. The request is for a document (not API, assets, etc.)
  3. The URL locale doesn't match the detected locale

Redirects use HTTP 307 (Temporary Redirect) to preserve the request method.

Controlling Redirects

To prevent redirects and let the URL always determine the locale:

// Put URL first in strategy - URL always wins
strategy: ["url", "cookie", "baseLocale"];

To allow cookie/preference to override URL (causes redirects):

// Cookie takes precedence - may redirect to match cookie locale
strategy: ["cookie", "url", "baseLocale"];

AsyncLocalStorage

The middleware uses AsyncLocalStorage (opens in a new tab) to isolate locale state between concurrent requests.

Why It Matters

Without request isolation, concurrent requests could interfere:

Request A (locale: de) ─────────────────────────────────────►
                          Request B (locale: en) ──────────►

                          └─ Without isolation, Request A might
                             suddenly see locale "en" here!

Disabling AsyncLocalStorage

[!WARNING] Only disable AsyncLocalStorage when your runtime does not provide AsyncLocalStorage or node:async_hooks and also guarantees request isolation. Keep the default on Vercel Edge and on Cloudflare Workers when Node.js compatibility (nodejs_compat) is enabled.

paraglideVitePlugin({
  project: "./project.inlang",
  outdir: "./src/paraglide",
  disableAsyncLocalStorage: true, // Compatibility fallback
});

Troubleshooting

getLocale() returns wrong locale

Cause

Calling getLocale() outside the middleware context.

Solution

Ensure getLocale() is called inside the middleware callback:

// ❌ Wrong - outside middleware
const locale = getLocale(); // Returns server's default locale
 
app.use((req) => {
  return paraglideMiddleware(req, ({ locale }) => {
    // ✅ Correct - inside middleware
    const locale = getLocale(); // Returns request's locale
  });
});

Redirect loops

Cause

Both the middleware AND your framework are handling URL localization/delocalization.

Solution

Pass the original request to your framework instead of the modified one:

// ❌ Causes loop
paraglideMiddleware(req, ({ request }) => handler(request));
 
// ✅ Fixes loop
paraglideMiddleware(req, () => handler(req));

The middleware still handles locale detection, cookies, and AsyncLocalStorage context - only the URL delocalization is bypassed.

Why this happens

Some frameworks like TanStack Router handle URL localization themselves via rewrite APIs (e.g., rewrite.input/rewrite.output). The paraglideMiddleware() also de-localizes URLs when the URL strategy is used (e.g., /en/about/about). If both do it, you get a conflict:

1. Request: /en/about
2. Middleware delocalizes → /about
3. Framework localizes → /en/about
4. Middleware delocalizes → /about
5. ... (infinite loop)

Frameworks that handle URL localization

  • TanStack Router/Start - Uses deLocalizeUrl/localizeUrl in rewrite options
  • Other frameworks with built-in i18n URL rewriting

Locale bleeds between requests

Cause

AsyncLocalStorage disabled in a multi-request environment.

Solution

Ensure AsyncLocalStorage is enabled (the default):

paraglideVitePlugin({
  project: "./project.inlang",
  outdir: "./src/paraglide",
  // Don't set this to true unless your runtime lacks AsyncLocalStorage
  // and guarantees per-request isolation
  // disableAsyncLocalStorage: true,
});

If you must disable it, ensure your environment isolates requests and does not share state across concurrent requests.

Cookies not being set

Cause

Cookie strategy is configured but the cookie isn't being sent to the browser.

Solution

Paraglide middleware doesn't set cookies automatically. Use setLocale() on the client:

import { setLocale } from "./paraglide/runtime.js";
 
// On the client - this updates the cookie automatically
setLocale("de");

Or set it manually in your server response:

return new Response(body, {
  headers: {
    "Set-Cookie": `PARAGLIDE_LOCALE=${locale}; Path=/; Max-Age=31536000`,
  },
});

Custom Strategies

You can define custom locale detection strategies alongside built-in ones.

Client-Side Custom Strategy

import { defineCustomClientStrategy } from "./paraglide/runtime.js";
 
defineCustomClientStrategy("custom-sessionStorage", {
  getLocale: () => sessionStorage.getItem("locale") ?? undefined,
  setLocale: (locale) => sessionStorage.setItem("locale", locale),
});

Server-Side Custom Strategy

import { defineCustomServerStrategy } from "./paraglide/runtime.js";
 
// Sync example
defineCustomServerStrategy("custom-header", {
  getLocale: (request) => request?.headers.get("X-Locale") ?? undefined,
});
 
// Async example (database lookup)
defineCustomServerStrategy("custom-database", {
  getLocale: async (request) => {
    const userId = extractUserId(request);
    if (!userId) return undefined;
    return await getUserLocaleFromDB(userId);
  },
});

Using Custom Strategies

paraglideVitePlugin({
  project: "./project.inlang",
  outdir: "./src/paraglide",
  strategy: ["custom-header", "url", "cookie", "baseLocale"],
});

Custom strategies must be named custom-<name> and are evaluated in order with other strategies.

See Also