Paraglide JS

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 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

For SvelteKit 2, import Handle from @sveltejs/kit instead of @sveltejs/kit/hooks.

// src/hooks.server.ts
import { paraglideMiddleware } from "./paraglide/server.js";
import type { Handle } from "@sveltejs/kit/hooks";

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 matches the redirect mode (by default, Sec-Fetch-Dest: document)
  3. The URL locale doesn't match the detected locale

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

Redirecting HTML navigations

HTTP clients that omit Sec-Fetch-Dest, such as curl, do not receive redirects by default. Use redirect: "navigation" to include GET and HEAD requests whose Accept header allows HTML:

return paraglideMiddleware(request, ({ request }) => resolve(request), {
	redirect: "navigation",
});

This mode redirects only GET and HEAD requests. They must have either Sec-Fetch-Dest: document, or no Sec-Fetch-Dest and an explicit Accept header allowing text/html, text/*, or */*. Quality values are respected: text/html;q=0, */* excludes HTML. An absent or empty Accept does not enable the fallback. Other destinations, including empty, do not enable it either.

POST requests, including form submissions with Sec-Fetch-Dest: document, continue to the resolve callback without a middleware redirect. The default redirect: "document" keeps its existing behavior, including 307 redirects for form submissions. Unrecognized option values also use the default behavior.

With this option, a client that accepts HTML can reach the localized URL instead of receiving the page at an unprefixed URL. The locale strategies still decide whether a redirect is needed. Put url before cookie and preferredLanguage if clients should be able to visit every localized URL. Otherwise a client whose preference differs from the URL can be redirected away from that locale. See Controlling Redirects below.

In this mode, when url is first, the URL resolves a locale and no custom strategy is configured, the middleware adds Vary only for a pending redirect. Canonicalization redirects then vary by Accept and Sec-Fetch-Dest, without the preference headers.

Other GET/HEAD routes using the URL strategy receive the navigation fields. Redirects also vary by Cookie and Accept-Language when those strategies are configured. Callback responses use those preference fields when the preferences precede the URL strategy, the URL supplies no locale, or a custom strategy is configured. This separates cached 200 and 307 responses while avoiding unnecessary variants on canonical pages. Existing Vary fields, including *, are preserved. The onRedirect callback runs only for middleware redirects.

When Vary fields are missing, mutable callback Response headers are updated in place, preserving response identity, subclass properties and runtime-specific state such as Cloudflare Workers body encoding. Responses with immutable headers are rebuilt using the original response as the constructor options; their status, headers and body are preserved, but subclass properties are not copied. Immutable responses that cannot be rebuilt keep their existing headers. Integrations that handle responses outside the callback must set these fields themselves. Custom strategies must also account for any additional headers they read.

Locale splitting skips values without response headers or a body reader, and responses with consumed or locked bodies.

API clients sending Accept: */* also qualify. Keep the default mode or exclude API routes with routeStrategies if they should never redirect.

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 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",
	emitTsDeclarations: true,
	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",
	emitTsDeclarations: true,
	// 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",
	emitTsDeclarations: true,
	strategy: ["custom-header", "url", "cookie", "baseLocale"],
});

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

See Also