Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions demo/new-compiler-next16/app/.lingo/locale-resolver-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Custom path-based locale resolver for Next.js client-side
*
* These are utility functions (not hooks) that can be called from anywhere,
* including inside callbacks, event handlers, etc.
*
* Note: We use window.location instead of Next.js hooks because these functions
* are called from within useCallback/event handlers where hooks cannot be used.
*/

"use client";

import type { LocaleCode } from "@lingo.dev/compiler"
import { sourceLocale } from "../../supported-locales";

/**
* Get locale from the current pathname
*
* This is a regular function (not a hook) that can be called from anywhere.
* It reads from window.location.pathname to extract the locale.
*
* @returns Locale code extracted from path or default locale
*/
export function getClientLocale(): LocaleCode {
if (typeof window === "undefined") {
return sourceLocale;
}

try {
const pathname = window.location.pathname;
const segments = pathname.split("/").filter(Boolean);
const potentialLocale = segments[0];

if (potentialLocale) {
return potentialLocale as LocaleCode;
}
Comment on lines +29 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing validation against supported locales.

Unlike proxy.ts which validates SUPPORTED_LOCALES.includes(potentialLocale), this function returns any first path segment as the locale without validation. This could cause client/server mismatch if an invalid locale somehow reaches the URL.

Consider validating against the supported locales list:

🛡️ Proposed fix
 import type { LocaleCode } from "@lingo.dev/compiler"
-import { sourceLocale } from "../../supported-locales";
+import { sourceLocale, supportedLocales } from "../../supported-locales";
 
 // ... in getClientLocale():
     const potentialLocale = segments[0];
 
-    if (potentialLocale) {
-      return potentialLocale as LocaleCode;
+    if (potentialLocale && supportedLocales.includes(potentialLocale as LocaleCode)) {
+      return potentialLocale as LocaleCode;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/new-compiler-next16/app/.lingo/locale-resolver-client.ts` around lines
29 - 36, The resolver currently returns the first path segment (potentialLocale)
without checking it against the supported list; update the logic in
locale-resolver-client.ts to validate potentialLocale with SUPPORTED_LOCALES
(e.g., if (potentialLocale && SUPPORTED_LOCALES.includes(potentialLocale)))
before returning it as LocaleCode, otherwise fall back to the existing
fallback/undefined behavior so the client matches the server's locale
validation.


return sourceLocale;
} catch (error) {
console.error("Error resolving locale from path:", error);
return sourceLocale;
}
}

/**
* Get the pathname for a given locale
*
* This is a utility function that computes what the path should be for a locale change.
* It doesn't perform navigation - the caller is responsible for that.
*
* @param locale - Locale to switch to
* @returns The new pathname with the locale prefix
*/
function getLocalePathname(locale: LocaleCode): string {
if (typeof window === "undefined") {
return `/${locale}`;
}

try {
const pathname = window.location.pathname;
const segments = pathname.split("/").filter(Boolean);

// Replace the first segment (current locale) with the new locale
if (segments[0]) {
segments[0] = locale;
} else {
// If no segments, just add the locale
segments.unshift(locale);
}

return "/" + segments.join("/");
} catch (error) {
console.error("Error computing locale pathname:", error);
return `/${locale}`;
}
}

/**
* Returns new URL that will be used to navigate to the new locale
*
* @param locale - Locale to switch to
*/
export function persistLocale(locale: LocaleCode): string | undefined {
if (typeof window === "undefined") {
return;
}

try {
const newPath = getLocalePathname(locale);
const search = window.location.search;
const hash = window.location.hash;
return newPath + search + hash;
} catch (error) {
console.error("Error persisting locale:", error);
}
}
29 changes: 29 additions & 0 deletions demo/new-compiler-next16/app/.lingo/locale-resolver-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Custom path-based locale resolver for Next.js server-side
*
* This resolver uses the next-intl pattern:
* - Middleware extracts locale from URL path
* - Middleware sets x-lingo-locale header
* - Server components read this header
*
* This allows all Server Components to reliably access the locale
* without needing to parse URLs or receive it via props.
*
* Falls back to the default locale if header is not set.
*/

import { headers } from "next/headers";

/**
* Get locale from middleware-set header
*
* The middleware extracts the locale from the URL path (e.g., /es/about)
* and sets it in the x-lingo-locale header. This function reads that header.
*
* @returns Locale code from x-lingo-locale header or default locale
*/
export async function getServerLocale(): Promise<string> {
const headersList = await headers();
const locale = headersList.get("x-lingo-locale");
return locale || "en";
}
44 changes: 44 additions & 0 deletions demo/new-compiler-next16/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
import { LingoProvider } from "@lingo.dev/compiler/react/next";
import type { ReactNode } from "react";
import type { LocaleCode } from "@lingo.dev/compiler";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default async function LocaleLayout({
children,
params,
}: Readonly<{
children: ReactNode;
params: Promise<{ locale: LocaleCode }>;
}>) {
const { locale } = await params;

console.debug("LocaleLayout", { locale });
return (
<LingoProvider initialLocale={locale}>
<html lang={locale}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
</LingoProvider>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Counter } from "@/components/Counter";
import { LocaleSwitcher } from "@lingo.dev/compiler/react";
import Link from "next/link";
import { Link } from "@/components/LocaleLink";
import { ServerChild } from "@/components/ServerChild";
import { ClientChildWrapper } from "@/components/ClientChildWrapper";

Expand All @@ -22,7 +22,7 @@ export default function Home() {
<header className="flex justify-between p-10 w-full">
<span>Lingo.dev compiler Next demo</span>
<nav>
<Link href="/test">Test</Link> 1
<Link href="test">Test</Link> 1
</nav>
<LocaleSwitcher
locales={[
Expand Down
38 changes: 7 additions & 31 deletions demo/new-compiler-next16/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,13 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { LingoProvider } from "@lingo.dev/compiler/react/next";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
import { ReactNode } from "react";

/**
* Root layout - minimal wrapper
* The actual locale-aware layout is in [locale]/layout.tsx
*/
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
return (
<LingoProvider>
<html>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
</LingoProvider>
);
return children;
}
Comment on lines 7 to 13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Next.js 16 App Router: does the top-level app/layout.tsx root layout still require both <html> and <body> tags?

💡 Result:

Yes, the top-level app/layout.tsx root layout in Next.js 16 App Router still requires both <html> and <body> tags.

Citations:


Add <html> and <body> tags to the root layout.

The top-level app/layout.tsx must render <html> and <body> elements. Returning children directly will cause missing-root-layout-tags errors at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/new-compiler-next16/app/layout.tsx` around lines 7 - 13, The RootLayout
component currently returns children directly which causes
missing-root-layout-tags errors; update the export default function RootLayout
to return a root HTML structure by wrapping children in <html> and <body>
elements (e.g., return <html><body>{children}</body></html>), ensuring the
component still accepts the existing Readonly<{ children: ReactNode }> props and
exports as before.

19 changes: 19 additions & 0 deletions demo/new-compiler-next16/components/LocaleLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import NextLink from "next/link";
import { useLingoContext } from "@lingo.dev/compiler/react";
import type { ComponentProps } from "react";

/**
* Link component that automatically prefixes hrefs with current locale
*/
export function Link({ href, ...props }: ComponentProps<typeof NextLink>) {
const { locale } = useLingoContext();

// If href is already locale-prefixed or external, use as-is
const localizedHref = typeof href === "string" && !href.startsWith("http") && !href.startsWith(`/${locale}`)
? `/${locale}${href.startsWith("/") ? "" : "/"}${href}`
: href;
Comment on lines +13 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Locale prefix check only guards against current locale, not all supported locales.

If locale is "es" and href is "/en/about" (already prefixed with a different locale), this would incorrectly produce "/es/en/about".

Consider checking against all supported locales:

🐛 Proposed fix
+"use client";
+
+import NextLink from "next/link";
+import { useLingoContext } from "@lingo.dev/compiler/react";
+import type { ComponentProps } from "react";
+import { supportedLocales } from "@/supported-locales";
+
+/**
+ * Check if href already starts with a supported locale prefix
+ */
+function hasLocalePrefix(href: string): boolean {
+  return supportedLocales.some(
+    (loc) => href === `/${loc}` || href.startsWith(`/${loc}/`)
+  );
+}
+
 /**
  * Link component that automatically prefixes hrefs with current locale
  */
 export function Link({ href, ...props }: ComponentProps<typeof NextLink>) {
   const { locale } = useLingoContext();
 
-  // If href is already locale-prefixed or external, use as-is
-  const localizedHref = typeof href === "string" && !href.startsWith("http") && !href.startsWith(`/${locale}`)
+  // If href is already locale-prefixed or external, use as-is
+  const localizedHref = typeof href === "string" && !href.startsWith("http") && !hasLocalePrefix(href)
     ? `/${locale}${href.startsWith("/") ? "" : "/"}${href}`
     : href;
 
   return <NextLink href={localizedHref} {...props} />;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demo/new-compiler-next16/components/LocaleLink.tsx` around lines 13 - 16, The
locale-prefix check in LocaleLink.tsx (the localizedHref logic) only prevents
adding the current locale and can produce double prefixes like "/es/en/about";
update the condition to detect any supported locale prefix before prepending:
replace the !href.startsWith(`/${locale}`) check with a test against the app's
supported locales (e.g., supportedLocales or i18n.locales) so you only prepend
`/${locale}` when href does not start with "/" + anySupportedLocale (handle both
"/{locale}" and "/{locale}/" cases) and still skip external URLs starting with
"http".


return <NextLink href={localizedHref} {...props} />;
}
10 changes: 8 additions & 2 deletions demo/new-compiler-next16/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import type { NextConfig } from "next";
import { withLingo } from "@lingo.dev/compiler/next";
import { sourceLocale } from "./supported-locales";
import { targetLocales } from "@/supported-locales";

const nextConfig: NextConfig = {};

export default async function (): Promise<NextConfig> {
return await withLingo(nextConfig, {
sourceRoot: "./app",
lingoDir: ".lingo",
sourceLocale: "en",
targetLocales: ["es", "de", "ru"],
sourceLocale,
targetLocales,
useDirective: false, // Set to true to require 'use i18n' directive
models: "lingo.dev",
dev: {
usePseudotranslator: true,
},
buildMode: "cache-only",
// Use custom path-based locale resolver instead of cookies
localePersistence: {
type: "custom",
},
});
}
107 changes: 107 additions & 0 deletions demo/new-compiler-next16/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's 2026 why the hell Next asks users to configure all this hell when they want path based i18n

import { sourceLocale, supportedLocales } from "@/supported-locales";

const SUPPORTED_LOCALES = supportedLocales;
const DEFAULT_LOCALE = sourceLocale;


/**
* Get the preferred locale from Accept-Language header
*/
function getLocaleFromHeader(request: NextRequest): string | null {
const acceptLanguage = request.headers.get("accept-language");
if (!acceptLanguage) return null;

// Parse Accept-Language header (e.g., "en-US,en;q=0.9,es;q=0.8")
const languages = acceptLanguage
.split(",")
.map((lang) => {
const [code, qValue] = lang.trim().split(";q=");
const quality = qValue ? parseFloat(qValue) : 1.0;
// Extract base language code (e.g., "en" from "en-US")
const baseCode = code.split("-")[0].toLowerCase();
return { code: baseCode, quality };
})
.sort((a, b) => b.quality - a.quality);

// Find first supported locale
for (const { code } of languages) {
if (SUPPORTED_LOCALES.includes(code)) {
return code;
}
}

return null;
}

/**
* Extract locale from pathname
* Returns the locale code if found in the path, otherwise null
*/
function getLocaleFromPath(pathname: string): string | null {
// Extract first segment
const segments = pathname.split("/").filter(Boolean);
const potentialLocale = segments[0];

if (
potentialLocale &&
SUPPORTED_LOCALES.includes(potentialLocale)
) {
return potentialLocale;
}

return null;
}

/**
* Middleware to handle locale-based routing following Next.js 16 patterns
*
* Similar to next-intl's approach:
* - Detects locale from URL path first
* - Falls back to Accept-Language header for locale detection
* - Redirects to appropriate locale if missing
* - Sets x-lingo-locale header for Server Components (like next-intl does)
*/
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;

// Try to extract locale from path
const localeFromPath = getLocaleFromPath(pathname);

if (localeFromPath) {
// Already has locale in path, continue with request
// BUT add x-lingo-locale header so Server Components can read it
// This is the key pattern from next-intl!
const response = NextResponse.next();
response.headers.set("x-lingo-locale", localeFromPath);
return response;
}

// No locale in pathname - determine which locale to use
const preferredLocale = getLocaleFromHeader(request) || DEFAULT_LOCALE;

// Redirect to locale-prefixed path
const url = request.nextUrl.clone();
url.pathname = `/${preferredLocale}${pathname === "/" ? "" : pathname}`;

return NextResponse.redirect(url);
}

export const config = {
// Match all pathnames except for:
// - /api (API routes)
// - /_next (Next.js internals)
// - /_vercel (Vercel internals)
// - /favicon.ico, /robots.txt (static files in public)
// - Files with extensions (e.g., .js, .css, .png, .svg, etc.)
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next (Next.js internals)
* - _vercel (Vercel internals)
* - Files with extensions (static files)
*/
"/((?!api|_next|_vercel|.*\\..*).*)",
],
};
4 changes: 4 additions & 0 deletions demo/new-compiler-next16/supported-locales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { LocaleCode } from "@lingo.dev/compiler"
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a way to avoid repeating these

export const targetLocales: LocaleCode[] = ["es", "de", "ru"];
export const sourceLocale: LocaleCode = "en";
export const supportedLocales: LocaleCode[] = [...targetLocales, sourceLocale];
1 change: 1 addition & 0 deletions packages/new-compiler/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export type { PartialLingoConfig } from "./types";
export type { LocaleCode } from "lingo.dev/spec";
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to reexport I think, because it's used in many places, and compiler could be the single dependency.

Loading
Loading