diff --git a/docs/router/config.json b/docs/router/config.json index 808c3fe38f9..a1fab7b8bc9 100644 --- a/docs/router/config.json +++ b/docs/router/config.json @@ -324,6 +324,10 @@ { "label": "Render Optimizations", "to": "framework/react/guide/render-optimizations" + }, + { + "label": "Internationalization (i18n)", + "to": "framework/react/guide/internationalization-i18n" } ] }, diff --git a/docs/router/framework/react/guide/internationalization-i18n.md b/docs/router/framework/react/guide/internationalization-i18n.md index 49397717772..06a6bd544d5 100644 --- a/docs/router/framework/react/guide/internationalization-i18n.md +++ b/docs/router/framework/react/guide/internationalization-i18n.md @@ -11,33 +11,16 @@ This guide covers: - Language navigation and switching - SEO considerations - Type safety -- Integration patterns with i18n libraries (Paraglide) +- Integration patterns with i18n libraries (e.g. Paraglide) ---- - -## i18n with Optional Path Parameters - -This pattern relies exclusively on TanStack Router features. It is suitable when: - -- You want full control over translations -- You already manage translations manually -- You do not need automatic locale detection - -Optional path parameters are ideal for implementing locale-aware routing without duplicating routes. - -```ts -;/{-$locale}/abotu -``` +## Internationalization (i18n) with Optional Path Parameters -This single route matches: - -- `/about` (default locale) -- `/en/about` -- `/fr/about` -- `/es/about` +Optional path parameters are excellent for implementing internationalization (i18n) routing patterns. You can use prefix patterns to handle multiple languages while maintaining clean, SEO-friendly URLs. ### Prefix-based i18n +Use optional language prefixes to support URLs like `/en/about`, `/fr/about`, or just `/about` (default language): + ```tsx // Route: /{-$locale}/about export const Route = createFileRoute('/{-$locale}/about')({ @@ -46,86 +29,328 @@ export const Route = createFileRoute('/{-$locale}/about')({ function AboutComponent() { const { locale } = Route.useParams() - const currentLocale = locale || 'en' + const currentLocale = locale || 'en' // Default to English const content = { - en: { title: 'About Us' }, - fr: { title: 'À Propos' }, - es: { title: 'Acerca de' }, + en: { title: 'About Us', description: 'Learn more about our company.' }, + fr: { + title: 'À Propos', + description: 'En savoir plus sur notre entreprise.', + }, + es: { + title: 'Acerca de', + description: 'Conoce más sobre nuestra empresa.', + }, } - return

{content[currentLocale].title}

+ return ( +
+

{content[currentLocale]?.title}

+

{content[currentLocale]?.description}

+
+ ) } ``` -### Complex Routing Patterns +This pattern matches: + +- `/about` (default locale) +- `/en/about` (explicit English) +- `/fr/about` (French) +- `/es/about` (Spanish) + +### Complex i18n Patterns + +Combine optional parameters for more sophisticated i18n routing: ```tsx // Route: /{-$locale}/blog/{-$category}/$slug export const Route = createFileRoute('/{-$locale}/blog/{-$category}/$slug')({ - beforeLoad: ({ params }) => { + beforeLoad: async ({ params }) => { const locale = params.locale || 'en' - const validLocales = ['en', 'fr', 'es', 'de'] + const category = params.category - if (params.locale && !validLocales.includes(params.locale)) { + // Validate locale and category + const validLocales = ['en', 'fr', 'es', 'de'] + if (locale && !validLocales.includes(locale)) { throw new Error('Invalid locale') } - return { locale } + return { locale, category } + }, + loader: async ({ params, context }) => { + const { locale } = context + const { slug, category } = params + + return fetchBlogPost({ slug, category, locale }) }, + component: BlogPostComponent, }) + +function BlogPostComponent() { + const { locale, category, slug } = Route.useParams() + const data = Route.useLoaderData() + + return ( +
+

{data.title}

+

+ Category: {category || 'All'} | Language: {locale || 'en'} +

+
{data.content}
+
+ ) +} ``` -### Language Switching +This supports URLs like: + +- `/blog/tech/my-post` (default locale, tech category) +- `/fr/blog/my-post` (French, no category) +- `/en/blog/tech/my-post` (explicit English, tech category) +- `/es/blog/tecnologia/mi-post` (Spanish, Spanish category) + +### Language Navigation + +Create language switchers using optional i18n parameters with function-style params: ```tsx - ({ - ...prev, - locale: prev.locale === 'en' ? undefined : 'fr', - })} -> - Français - +function LanguageSwitcher() { + const currentParams = useParams({ strict: false }) + + const languages = [ + { code: 'en', name: 'English' }, + { code: 'fr', name: 'Français' }, + { code: 'es', name: 'Español' }, + ] + + return ( +
+ {languages.map(({ code, name }) => ( + ({ + ...prev, + locale: code === 'en' ? undefined : code, // Remove 'en' for clean URLs + })} + className={currentParams.locale === code ? 'active' : ''} + > + {name} + + ))} +
+ ) +} ``` -### Type-safe Locales +You can also create more sophisticated language switching logic: -```ts -type Locale = 'en' | 'fr' | 'es' | 'de' +```tsx +function AdvancedLanguageSwitcher() { + const currentParams = useParams({ strict: false }) + + const handleLanguageChange = (newLocale: string) => { + return (prev: any) => { + // Preserve all existing params but update locale + const updatedParams = { ...prev } + + if (newLocale === 'en') { + // Remove locale for clean English URLs + delete updatedParams.locale + } else { + updatedParams.locale = newLocale + } + + return updatedParams + } + } -function isLocale(value?: string): value is Locale { - return ['en', 'fr', 'es', 'de'].includes(value as Locale) + return ( +
+ + Français + + + + Español + + + + English + +
+ ) } ``` ---- +### Advanced i18n with Optional Parameters -## i18n Library Integration Patterns +Organize i18n routes using optional parameters for flexible locale handling: -TanStack Router is **library-agnostic**. You can integrate any i18n solution by mapping locale state to routing behavior. +```tsx +// Route structure: +// routes/ +// {-$locale}/ +// index.tsx // /, /en, /fr +// about.tsx // /about, /en/about, /fr/about +// blog/ +// index.tsx // /blog, /en/blog, /fr/blog +// $slug.tsx // /blog/post, /en/blog/post, /fr/blog/post + +// routes/{-$locale}/index.tsx +export const Route = createFileRoute('/{-$locale}/')({ + component: HomeComponent, +}) -Below is a recommended pattern using **Paraglide**. +function HomeComponent() { + const { locale } = Route.useParams() + const isRTL = ['ar', 'he', 'fa'].includes(locale || '') + + return ( +
+

Welcome ({locale || 'en'})

+ {/* Localized content */} +
+ ) +} ---- +// routes/{-$locale}/about.tsx +export const Route = createFileRoute('/{-$locale}/about')({ + component: AboutComponent, +}) +``` + +### SEO and Canonical URLs + +Handle SEO for i18n routes properly: + +```tsx +export const Route = createFileRoute('/{-$locale}/products/$id')({ + component: ProductComponent, + head: ({ params, loaderData }) => { + const locale = params.locale || 'en' + const product = loaderData + + return { + title: product.title[locale] || product.title.en, + meta: [ + { + name: 'description', + content: product.description[locale] || product.description.en, + }, + { + property: 'og:locale', + content: locale, + }, + ], + links: [ + // Canonical URL (always use default locale format) + { + rel: 'canonical', + href: `https://example.com/products/${params.id}`, + }, + // Alternate language versions + { + rel: 'alternate', + hreflang: 'en', + href: `https://example.com/products/${params.id}`, + }, + { + rel: 'alternate', + hreflang: 'fr', + href: `https://example.com/fr/products/${params.id}`, + }, + { + rel: 'alternate', + hreflang: 'es', + href: `https://example.com/es/products/${params.id}`, + }, + ], + } + }, +}) +``` + +### Type Safety for i18n + +Ensure type safety for your i18n implementations: + +```tsx +// Define supported locales +type Locale = 'en' | 'fr' | 'es' | 'de' + +// Type-safe locale validation +function validateLocale(locale: string | undefined): locale is Locale { + return ['en', 'fr', 'es', 'de'].includes(locale as Locale) +} -## Client-side i18n with a Library (TanStack Router) +export const Route = createFileRoute('/{-$locale}/shop/{-$category}')({ + beforeLoad: async ({ params }) => { + const { locale } = params -This pattern combines TanStack Router with a client-side i18n library. It is suitable when: + // Type-safe locale validation + if (locale && !validateLocale(locale)) { + throw redirect({ + to: '/shop/{-$category}', + params: { category: params.category }, + }) + } + + return { + locale: (locale as Locale) || 'en', + isDefaultLocale: !locale || locale === 'en', + } + }, + component: ShopComponent, +}) -- You want type-safe translations -- You want localized URLs -- You do not need server-side rendering +function ShopComponent() { + const { locale, category } = Route.useParams() + const { isDefaultLocale } = Route.useRouteContext() + + // TypeScript knows locale is Locale | undefined + // and we have validated it in beforeLoad + + return ( +
+

Shop {category ? `- ${category}` : ''}

+

Language: {locale || 'en'}

+ {!isDefaultLocale && ( + + View in English + + )} +
+ ) +} +``` -### TanStack Router + Paraglide (Client-only) +Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. + +## i18n Library Integration Patterns + +TanStack Router is **library-agnostic**. You can integrate any i18n solution by mapping locale state to routing behavior. + +Below is a recommended pattern using **Paraglide**. + +### TanStack Router + Paraglide Paraglide provides type-safe translations, locale detection, and URL localization that pair naturally with TanStack Router. **GitHub example:** [https://github.com/TanStack/router/tree/main/examples/react/i18n-paraglide](https://github.com/TanStack/router/tree/main/examples/react/i18n-paraglide) -### Project Setup +#### Project Setup ```bash npx @inlang/paraglide-js@latest init @@ -140,7 +365,7 @@ paraglideVitePlugin({ }) ``` -### URL Localization via Router Rewrite +#### URL Localization via Router rewrite ```ts import { deLocalizeUrl, localizeUrl } from './paraglide/runtime' @@ -154,45 +379,7 @@ const router = createRouter({ }) ``` ---- - -## Server-side i18n (TanStack Start) - -This pattern integrates i18n at the routing and server layers. It is suitable when: - -- You use TanStack Start -- You need SSR or streaming -- You want locale-aware redirects and metadata - -### TanStack Start + Paraglide - -**GitHub example:** -[https://github.com/TanStack/router/tree/main/examples/react/start-i18n-paraglide](https://github.com/TanStack/router/tree/main/examples/react/start-i18n-paraglide) - -### Server Middleware (SSR) - -```ts -import { paraglideMiddleware } from './paraglide/server' - -export default { - fetch(req: Request) { - return paraglideMiddleware(req, () => handler.fetch(req)) - }, -} -``` - -### HTML Language Attribute - -```tsx -import { getLocale } from '../paraglide/runtime' -; -``` - ---- - -## Offline-safe Redirects - -For offline or client-only environments: +#### URL redirects ```ts import { shouldRedirect } from '../paraglide/runtime' @@ -205,44 +392,86 @@ beforeLoad: async () => { } ``` ---- +If you use TanStack Start and do not need offline capabilities, you don't need to use the shouldRedirect logic, only paraglideMiddleware in the TanStack Start Paraglide integration guide. -## Type-safe Translated Pathnames +#### Type-safe Translated Pathnames -To ensure every route has translations, you can derive translated pathnames directly from the TanStack Router route tree. +If you use translated pathnames, you can derive them directly from the TanStack Router route tree to ensure every route has translations. ```ts -import { FileRoutesByTo } from '../routeTree.gen' -import { Locale } from '@/paraglide/runtime' -``` +import { Locale } from "@reland/i18n/runtime" +import { RoutePath } from "../../types/Routes" -This guarantees: +const excludedPaths = ["admin", "partner", "tests", "api"] as const -- No missing translations -- Full type safety -- Compiler feedback for routing mistakes +type PublicRoutePath = Exclude< + RoutePath, + `${string}${(typeof excludedPaths)[number]}${string}` +> ---- +type TranslatedPathname = { + pattern: string + localized: Array<[Locale, string]> +} -## Prerendering Localized Routes +function toUrlPattern(path: string) { + return ( + path + // explicit catch-all: "/$" → "/:path(.*)?" + .replace(/\/\$$/, "/:path(.*)?") + // optional params like {-$param} → ":param(.*)?" + .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ":$1(.*)?") + // normal params like $param → ":param(.*)?" + .replace(/\$([a-zA-Z0-9_]+)/g, ":$1(.*)?") + // remove any remaining braces (safety) + .replace(/[{}]/g, "") + // remove trailing slash + .replace(/\/+$/, "") + ) +} -```ts -import { localizeHref } from './paraglide/runtime' +function createTranslatedPathnames( + input: Record>, +): TranslatedPathname[] { + return Object.entries(input).map(([pattern, locales]) => ({ + pattern: toUrlPattern(pattern), + localized: Object.entries(locales).map( + ([locale, path]) => + [locale as Locale, `/${locale}${toUrlPattern(path)}`] satisfies [ + Locale, + string, + ], + ), + })) +} -export const prerenderRoutes = ['/', '/about'].map((path) => ({ - path: localizeHref(path), - prerender: { enabled: true }, -})) +export const translatedPathnames = createTranslatedPathnames({ + "/": { + en: "/", + de: "/" + }, + "/about": { + en: "/about", + de: "/ueber" + }) ``` ---- +Use in vite.config.ts: -## Additional i18n Integration Patterns +```ts +paraglideVitePlugin({ + project: './project.inlang', + outdir: './app/paraglide', + urlPatterns: translatedPathnames, +}) +``` -### Intlayer (TanStack Start integration) +This guarantees: -[https://intlayer.org/doc/environment/tanstack-start](https://intlayer.org/doc/environment/tanstack-start) +- No missing translations +- Full type safety +- Compiler feedback for routing mistakes -### use-intl (TanStack Start integration) +## Looking for i18n with SSR/TanStack Start? -[https://nikuscs.com/blog/13-tanstackstart-i18n/](https://nikuscs.com/blog/13-tanstackstart-i18n/) +Check out the guide on integrating [i18n in TanStack Start](https://tanstack.com/start/latest/docs/framework/react/guide/internationalization-i18n). diff --git a/docs/router/framework/react/guide/path-params.md b/docs/router/framework/react/guide/path-params.md index 4ebd477ace2..8925812cb44 100644 --- a/docs/router/framework/react/guide/path-params.md +++ b/docs/router/framework/react/guide/path-params.md @@ -414,330 +414,6 @@ function PostsComponent() { ``` -## Internationalization (i18n) with Optional Path Parameters - -Optional path parameters are excellent for implementing internationalization (i18n) routing patterns. You can use prefix patterns to handle multiple languages while maintaining clean, SEO-friendly URLs. - -### Prefix-based i18n - -Use optional language prefixes to support URLs like `/en/about`, `/fr/about`, or just `/about` (default language): - -```tsx -// Route: /{-$locale}/about -export const Route = createFileRoute('/{-$locale}/about')({ - component: AboutComponent, -}) - -function AboutComponent() { - const { locale } = Route.useParams() - const currentLocale = locale || 'en' // Default to English - - const content = { - en: { title: 'About Us', description: 'Learn more about our company.' }, - fr: { - title: 'À Propos', - description: 'En savoir plus sur notre entreprise.', - }, - es: { - title: 'Acerca de', - description: 'Conoce más sobre nuestra empresa.', - }, - } - - return ( -
-

{content[currentLocale]?.title}

-

{content[currentLocale]?.description}

-
- ) -} -``` - -This pattern matches: - -- `/about` (default locale) -- `/en/about` (explicit English) -- `/fr/about` (French) -- `/es/about` (Spanish) - -### Complex i18n Patterns - -Combine optional parameters for more sophisticated i18n routing: - -```tsx -// Route: /{-$locale}/blog/{-$category}/$slug -export const Route = createFileRoute('/{-$locale}/blog/{-$category}/$slug')({ - beforeLoad: async ({ params }) => { - const locale = params.locale || 'en' - const category = params.category - - // Validate locale and category - const validLocales = ['en', 'fr', 'es', 'de'] - if (locale && !validLocales.includes(locale)) { - throw new Error('Invalid locale') - } - - return { locale, category } - }, - loader: async ({ params, context }) => { - const { locale } = context - const { slug, category } = params - - return fetchBlogPost({ slug, category, locale }) - }, - component: BlogPostComponent, -}) - -function BlogPostComponent() { - const { locale, category, slug } = Route.useParams() - const data = Route.useLoaderData() - - return ( -
-

{data.title}

-

- Category: {category || 'All'} | Language: {locale || 'en'} -

-
{data.content}
-
- ) -} -``` - -This supports URLs like: - -- `/blog/tech/my-post` (default locale, tech category) -- `/fr/blog/my-post` (French, no category) -- `/en/blog/tech/my-post` (explicit English, tech category) -- `/es/blog/tecnologia/mi-post` (Spanish, Spanish category) - -### Language Navigation - -Create language switchers using optional i18n parameters with function-style params: - -```tsx -function LanguageSwitcher() { - const currentParams = useParams({ strict: false }) - - const languages = [ - { code: 'en', name: 'English' }, - { code: 'fr', name: 'Français' }, - { code: 'es', name: 'Español' }, - ] - - return ( -
- {languages.map(({ code, name }) => ( - ({ - ...prev, - locale: code === 'en' ? undefined : code, // Remove 'en' for clean URLs - })} - className={currentParams.locale === code ? 'active' : ''} - > - {name} - - ))} -
- ) -} -``` - -You can also create more sophisticated language switching logic: - -```tsx -function AdvancedLanguageSwitcher() { - const currentParams = useParams({ strict: false }) - - const handleLanguageChange = (newLocale: string) => { - return (prev: any) => { - // Preserve all existing params but update locale - const updatedParams = { ...prev } - - if (newLocale === 'en') { - // Remove locale for clean English URLs - delete updatedParams.locale - } else { - updatedParams.locale = newLocale - } - - return updatedParams - } - } - - return ( -
- - Français - - - - Español - - - - English - -
- ) -} -``` - -### Advanced i18n with Optional Parameters - -Organize i18n routes using optional parameters for flexible locale handling: - -```tsx -// Route structure: -// routes/ -// {-$locale}/ -// index.tsx // /, /en, /fr -// about.tsx // /about, /en/about, /fr/about -// blog/ -// index.tsx // /blog, /en/blog, /fr/blog -// $slug.tsx // /blog/post, /en/blog/post, /fr/blog/post - -// routes/{-$locale}/index.tsx -export const Route = createFileRoute('/{-$locale}/')({ - component: HomeComponent, -}) - -function HomeComponent() { - const { locale } = Route.useParams() - const isRTL = ['ar', 'he', 'fa'].includes(locale || '') - - return ( -
-

Welcome ({locale || 'en'})

- {/* Localized content */} -
- ) -} - -// routes/{-$locale}/about.tsx -export const Route = createFileRoute('/{-$locale}/about')({ - component: AboutComponent, -}) -``` - -### SEO and Canonical URLs - -Handle SEO for i18n routes properly: - -```tsx -export const Route = createFileRoute('/{-$locale}/products/$id')({ - component: ProductComponent, - head: ({ params, loaderData }) => { - const locale = params.locale || 'en' - const product = loaderData - - return { - title: product.title[locale] || product.title.en, - meta: [ - { - name: 'description', - content: product.description[locale] || product.description.en, - }, - { - property: 'og:locale', - content: locale, - }, - ], - links: [ - // Canonical URL (always use default locale format) - { - rel: 'canonical', - href: `https://example.com/products/${params.id}`, - }, - // Alternate language versions - { - rel: 'alternate', - hreflang: 'en', - href: `https://example.com/products/${params.id}`, - }, - { - rel: 'alternate', - hreflang: 'fr', - href: `https://example.com/fr/products/${params.id}`, - }, - { - rel: 'alternate', - hreflang: 'es', - href: `https://example.com/es/products/${params.id}`, - }, - ], - } - }, -}) -``` - -### Type Safety for i18n - -Ensure type safety for your i18n implementations: - -```tsx -// Define supported locales -type Locale = 'en' | 'fr' | 'es' | 'de' - -// Type-safe locale validation -function validateLocale(locale: string | undefined): locale is Locale { - return ['en', 'fr', 'es', 'de'].includes(locale as Locale) -} - -export const Route = createFileRoute('/{-$locale}/shop/{-$category}')({ - beforeLoad: async ({ params }) => { - const { locale } = params - - // Type-safe locale validation - if (locale && !validateLocale(locale)) { - throw redirect({ - to: '/shop/{-$category}', - params: { category: params.category }, - }) - } - - return { - locale: (locale as Locale) || 'en', - isDefaultLocale: !locale || locale === 'en', - } - }, - component: ShopComponent, -}) - -function ShopComponent() { - const { locale, category } = Route.useParams() - const { isDefaultLocale } = Route.useRouteContext() - - // TypeScript knows locale is Locale | undefined - // and we have validated it in beforeLoad - - return ( -
-

Shop {category ? `- ${category}` : ''}

-

Language: {locale || 'en'}

- {!isDefaultLocale && ( - - View in English - - )} -
- ) -} -``` - -Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. - ## Allowed Characters By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property). diff --git a/docs/start/config.json b/docs/start/config.json index 396b1be4af6..ae557ce7d77 100644 --- a/docs/start/config.json +++ b/docs/start/config.json @@ -180,6 +180,10 @@ { "label": "LLM Optimization (LLMO)", "to": "framework/react/guide/llmo" + }, + { + "label": "Internationalization (i18n)", + "to": "framework/react/guide/internationalization-i18n" } ] }, diff --git a/docs/start/framework/react/guide/internationalization-i18n.md b/docs/start/framework/react/guide/internationalization-i18n.md new file mode 100644 index 00000000000..e36e293a0a6 --- /dev/null +++ b/docs/start/framework/react/guide/internationalization-i18n.md @@ -0,0 +1,108 @@ +--- +title: Internationalization (i18n) +--- + +TanStack Router provides flexible and highly customizable primitives that can be composed to support common internationalization (i18n) routing patterns, such as **optional path parameters**, **route rewriting**, and **type-safe params**. This enables clean, SEO-friendly URLs, flexible locale handling, and seamless integration with i18n libraries. + +This guide covers: + +- Prefix-based and optional-locale routing +- Advanced routing patterns for i18n +- Language navigation and switching +- SEO considerations +- Type safety +- Integration patterns with i18n libraries (e.g. Paraglide) + +# Integration with TanStack Router + +The base of this guide is built with TanStack Router. Check out the guide on integrating [i18n in TanStack Router](https://tanstack.com/start/latest/docs/framework/react/guide/internationalization-i18n). + +If you have already set up TanStack Router with i18n, this guide will be suitable when: + +- You use TanStack Start +- You need SSR or streaming +- You want locale-aware redirects and metadata + +## Library integrations + +### TanStack Start + Paraglide + +**GitHub example:** +[https://github.com/TanStack/router/tree/main/examples/react/start-i18n-paraglide](https://github.com/TanStack/router/tree/main/examples/react/start-i18n-paraglide) + +First, check out [TanStack Router + Paraglide integration guide](https://tanstack.com/start/latest/docs/framework/react/guide/internationalization-i18n#tanstack-router-paraglide) + +#### Server Middleware (SSR) + +```ts +import { paraglideMiddleware } from './paraglide/server' + +export default { + fetch(req: Request) { + return paraglideMiddleware(req, () => handler.fetch(req)) + }, +} +``` + +#### HTML Language Attribute + +Set the lang attribute in html at \_\_root.tsx: + +```tsx +import { getLocale } from '../paraglide/runtime' + +function RootDocument({ children }: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {children} + + + + ) +} +``` + +#### Prerendering Localized Routes + +```ts +import { localizeHref } from './paraglide/runtime' + +export const prerenderRoutes = ['/', '/about'].map((path) => ({ + path: localizeHref(path), + prerender: { enabled: true }, +})) +``` + +In vite.config.ts: + +```ts +tanstackStart({ + prerender: { + // Enable prerendering + enabled: true, + + // Whether to extract links from the HTML and prerender them also + crawlLinks: true, + }, + pages: [ + { + path: '/my-page', + prerender: { enabled: true, outputPath: '/my-page/index.html' }, + }, + ], +}) +``` + +## Additional i18n Integration Patterns + +### Intlayer (TanStack Start integration) + +[https://intlayer.org/doc/environment/tanstack-start](https://intlayer.org/doc/environment/tanstack-start) + +### use-intl (TanStack Start integration) + +[https://nikuscs.com/blog/13-tanstackstart-i18n/](https://nikuscs.com/blog/13-tanstackstart-i18n/) diff --git a/e2e/react-start/i18n-paraglide/vite.config.ts b/e2e/react-start/i18n-paraglide/vite.config.ts index a0d8a6f77e1..ca6ad1c24dc 100644 --- a/e2e/react-start/i18n-paraglide/vite.config.ts +++ b/e2e/react-start/i18n-paraglide/vite.config.ts @@ -17,21 +17,21 @@ const config = defineConfig({ { pattern: '/', localized: [ - ['en', '/en'], + ['en', '/'], ['de', '/de'], ], }, { pattern: '/about', localized: [ - ['en', '/en/about'], + ['en', '/about'], ['de', '/de/ueber'], ], }, { pattern: '/:path(.*)?', localized: [ - ['en', '/en/:path(.*)?'], + ['en', '/:path(.*)?'], ['de', '/de/:path(.*)?'], ], }, diff --git a/examples/react/i18n-paraglide/README.md b/examples/react/i18n-paraglide/README.md index 98b25f6d0fe..63402f30ca3 100644 --- a/examples/react/i18n-paraglide/README.md +++ b/examples/react/i18n-paraglide/README.md @@ -1,132 +1,60 @@ -# TanStack Router - i18n with Paraglide Example +### TanStack Router + Paraglide -This example shows how to use Paraglide with TanStack Router. +Paraglide provides type-safe translations, locale detection, and URL localization that pair naturally with TanStack Router. -- [TanStack Router Docs](https://tanstack.com/router) -- [Paraglide Documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) - -## Start a new project based on this example - -To start a new project based on this example, run: - -```sh -npx gitpick TanStack/router/tree/main/examples/react/i18n-paraglide i18n-paraglide -``` - -## Getting started - -1. Init Paraglide JS +#### Project Setup ```bash npx @inlang/paraglide-js@latest init ``` -2. Add the vite plugin to your `vite.config.ts`: - -```diff -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { tanstackRouter } from '@tanstack/router-plugin/vite' -+import { paraglideVitePlugin } from "@inlang/paraglide-js"; - -export default defineConfig({ - plugins: [ - tanstackRouter({ target: 'react', autoCodeSplitting: true }), - react(), -+ paraglideVitePlugin({ -+ project: "./project.inlang", -+ outdir: "./app/paraglide", -+ }), - ], -}); -``` - -3. Done :) - -Run the app and start translating. See the [basics documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/basics) for information on how to use Paraglide's messages, parameters, and locale management. +```ts +import { paraglideVitePlugin } from '@inlang/paraglide-js' -## Rewrite URL +paraglideVitePlugin({ + project: './project.inlang', + outdir: './app/paraglide', +}) +``` -If you want to handle how the URL looks when the user changes the locale, you can rewrite the URL in the router. +#### URL Localization via Router rewrite -```diff -import { createRouter } from "@tanstack/react-router"; -import { routeTree } from "./routeTree.gen"; -+import { deLocalizeUrl, localizeUrl } from "./paraglide/runtime.js"; +```ts +import { deLocalizeUrl, localizeUrl } from './paraglide/runtime' const router = createRouter({ routeTree, -+ rewrite: { -+ input: ({ url }) => deLocalizeUrl(url), -+ output: ({ url }) => localizeUrl(url), + rewrite: { + input: ({ url }) => deLocalizeUrl(url), + output: ({ url }) => localizeUrl(url), }, -}); +}) ``` -In `__root.tsx` add a `beforeLoad` hook to check if the user should be redirected and set the html `lang` attribute. - -Intercept the request in `server.ts` with the paraglideMiddleware: +#### URL redirects ```ts -import { paraglideMiddleware } from './paraglide/server.js' -import handler from '@tanstack/react-start/server-entry' -export default { - fetch(req: Request): Promise { - return paraglideMiddleware(req, ({ request }) => handler.fetch(request)) - }, -} -``` - -In `__root.tsx` change the html lang attribute to the current locale. - -```tsx -import { getLocale } from '../paraglide/runtime.js' +import { shouldRedirect } from '../paraglide/runtime' -function RootDocument({ children }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - - - ) +beforeLoad: async () => { + const decision = await shouldRedirect({ url: window.location.href }) + if (decision.redirectUrl) { + throw redirect({ href: decision.redirectUrl.href }) + } } ``` -## Offline redirect - -If you have an application that needs to work offline, you will need to handle the redirect in the client like this. - -```ts -import { shouldRedirect } from "../paraglide/runtime"; - -export const Route = createRootRoute({ - beforeLoad: async () => { - const decision = await shouldRedirect({ url: window.location.href }); +If you use TanStack Start and do not need offline capabilities, you don't need to use the shouldRedirect logic, only paraglideMiddleware in the TanStack Start Paraglide integration guide. - if (decision.redirectUrl) { - throw redirect({ href: decision.redirectUrl.href }); - } - }, - ... -}); -``` +#### Type-safe Translated Pathnames -## Typesafe translated pathnames - -If you don't want to miss any translated path, you can create a `createTranslatedPathnames` function and pass it to the vite plugin. +If you use translated pathnames, you can derive them directly from the TanStack Router route tree to ensure every route has translations. ```ts -import { Locale } from '@/paraglide/runtime' -import { FileRoutesByTo } from '../routeTree.gen' - -type RoutePath = keyof FileRoutesByTo +import { Locale } from "@reland/i18n/runtime" +import { RoutePath } from "../../types/Routes" -const excludedPaths = ['admin', 'docs', 'api'] as const +const excludedPaths = ["admin", "partner", "tests", "api"] as const type PublicRoutePath = Exclude< RoutePath, @@ -141,14 +69,16 @@ type TranslatedPathname = { function toUrlPattern(path: string) { return ( path - // catch-all - .replace(/\/\$$/, '/:path(.*)?') - // optional parameters: {-$param} - .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ':$1?') - // named parameters: $param - .replace(/\$([a-zA-Z0-9_]+)/g, ':$1') + // explicit catch-all: "/$" → "/:path(.*)?" + .replace(/\/\$$/, "/:path(.*)?") + // optional params like {-$param} → ":param(.*)?" + .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ":$1(.*)?") + // normal params like $param → ":param(.*)?" + .replace(/\$([a-zA-Z0-9_]+)/g, ":$1(.*)?") + // remove any remaining braces (safety) + .replace(/[{}]/g, "") // remove trailing slash - .replace(/\/+$/, '') + .replace(/\/+$/, "") ) } @@ -168,43 +98,32 @@ function createTranslatedPathnames( } export const translatedPathnames = createTranslatedPathnames({ - '/': { - en: '/', - de: '/', + "/": { + en: "/", + es: "/" }, - '/about': { - en: '/about', - de: '/ueber', - }, -}) + "/about": { + en: "/about", + es: "/nosotros" + }) ``` -And import into the Paraglide Vite plugin. - -## Server-side rendering - -For server-side rendering, check out the [TanStack Start guide](https://github.com/TanStack/router/tree/main/examples/react/start-i18n-paraglide). - -## Prerender routes - -You can use the `localizeHref` function to map the routes to localized versions and import into the pages option in the TanStack Start plugin. For this to work you will need to compile paraglide before the build with the CLI. +Use in vite.config.ts: ```ts -import { localizeHref } from './paraglide/runtime' -export const prerenderRoutes = ['/', '/about'].map((path) => ({ - path: localizeHref(path), - prerender: { - enabled: true, - }, -})) +paraglideVitePlugin({ + project: './project.inlang', + outdir: './app/paraglide', + urlPatterns: translatedPathnames, +}) ``` -## About This Example +This guarantees: + +- No missing translations +- Full type safety +- Compiler feedback for routing mistakes -This example demonstrates: +## Looking for i18n with SSR/TanStack Start? -- Multi-language support with Paraglide -- Type-safe translations -- Locale-based routing -- Language switching -- i18n best practices +Check out the guide on integrating [i18n in TanStack Start](https://tanstack.com/start/latest/docs/framework/react/guide/internationalization-i18n). diff --git a/examples/react/start-i18n-paraglide/README.md b/examples/react/start-i18n-paraglide/README.md index 7f481e133c7..b23d0164247 100644 --- a/examples/react/start-i18n-paraglide/README.md +++ b/examples/react/start-i18n-paraglide/README.md @@ -1,141 +1,45 @@ -# TanStack Start example with Paraglide +### TanStack Start + Paraglide -This example shows how to use Paraglide with TanStack Start. +Paraglide provides type-safe translations, locale detection, and URL localization that pair naturally with TanStack Start. -- [TanStack Router Docs](https://tanstack.com/router) -- [Paraglide Documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) - -## Start a new project based on this example - -To start a new project based on this example, run: - -```sh -npx gitpick TanStack/router/tree/main/examples/react/start-i18n-paraglide start-i18n-paraglide -``` - -## Getting started - -1. Init Paraglide JS +#### Project Setup ```bash npx @inlang/paraglide-js@latest init ``` -2. Add the vite plugin to your `vite.config.ts`: - -```diff -import { defineConfig } from 'vite' -import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import react from '@vitejs/plugin-react' -+import { paraglideVitePlugin } from "@inlang/paraglide-js"; - -export default defineConfig({ - plugins: [ - tanstackStart(), - react(), -+ paraglideVitePlugin({ -+ project: "./project.inlang", -+ outdir: "./app/paraglide", -+ outputStructure: "message-modules", -+ cookieName: "PARAGLIDE_LOCALE", -+ strategy: ["url", "cookie", "preferredLanguage", "baseLocale"], -+ urlPatterns: [ -+ { -+ pattern: "/:path(.*)?", -+ localized: [ -+ ["en", "/en/:path(.*)?"], -+ ], -+ }, -+ ], -+ }), - ], -}); -``` - -3. Done :) - -Run the app and start translating. See the [basics documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/basics) for information on how to use Paraglide's messages, parameters, and locale management. - -## Rewrite URL - -If you want to handle how the URL looks when the user changes the locale, you can rewrite the URL in the router. - -```diff -import { createRouter } from "@tanstack/react-router"; -import { routeTree } from "./routeTree.gen"; -+import { deLocalizeUrl, localizeUrl } from "./paraglide/runtime.js"; - -const router = createRouter({ - routeTree, -+ rewrite: { -+ input: ({ url }) => deLocalizeUrl(url), -+ output: ({ url }) => localizeUrl(url), - }, -}); -``` - -In `server.ts` intercept the request with the paraglideMiddleware. - ```ts -import { paraglideMiddleware } from './paraglide/server.js' -import handler from '@tanstack/react-start/server-entry' -export default { - fetch(req: Request): Promise { - return paraglideMiddleware(req, () => handler.fetch(req)) - }, -} -``` - -In `__root.tsx` change the html lang attribute to the current locale. +import { paraglideVitePlugin } from '@inlang/paraglide-js' -```tsx -import { getLocale } from '../paraglide/runtime.js' - -function RootDocument({ children }: { children: React.ReactNode }) { - return ( - - - - - - {children} - - - - ) -} +paraglideVitePlugin({ + project: './project.inlang', + outdir: './app/paraglide', +}) ``` -## Offline redirect - -If you have an application that needs to work offline, you will need to handle the redirect in the client like this. +#### URL Localization via Router rewrite ```ts -import { shouldRedirect } from "../paraglide/runtime"; - -export const Route = createRootRoute({ - beforeLoad: async () => { - const decision = await shouldRedirect({ url: window.location.href }); +import { deLocalizeUrl, localizeUrl } from './paraglide/runtime' - if (decision.redirectUrl) { - throw redirect({ href: decision.redirectUrl.href }); - } +const router = createRouter({ + routeTree, + rewrite: { + input: ({ url }) => deLocalizeUrl(url), + output: ({ url }) => localizeUrl(url), }, - ... -}); +}) ``` -## Typesafe translated pathnames +#### Type-safe Translated Pathnames -If you don't want to miss any translated path, you can create a `createTranslatedPathnames` function and pass it to the vite plugin. +If you use translated pathnames, you can derive them directly from the TanStack Router route tree to ensure every route has translations. ```ts -import { Locale } from '@/paraglide/runtime' -import { FileRoutesByTo } from '../routeTree.gen' - -type RoutePath = keyof FileRoutesByTo +import { Locale } from "@reland/i18n/runtime" +import { RoutePath } from "../../types/Routes" -const excludedPaths = ['admin', 'docs', 'api'] as const +const excludedPaths = ["admin", "partner", "tests", "api"] as const type PublicRoutePath = Exclude< RoutePath, @@ -150,14 +54,16 @@ type TranslatedPathname = { function toUrlPattern(path: string) { return ( path - // catch-all - .replace(/\/\$$/, '/:path(.*)?') - // optional parameters: {-$param} - .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ':$1?') - // named parameters: $param - .replace(/\$([a-zA-Z0-9_]+)/g, ':$1') + // explicit catch-all: "/$" → "/:path(.*)?" + .replace(/\/\$$/, "/:path(.*)?") + // optional params like {-$param} → ":param(.*)?" + .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ":$1(.*)?") + // normal params like $param → ":param(.*)?" + .replace(/\$([a-zA-Z0-9_]+)/g, ":$1(.*)?") + // remove any remaining braces (safety) + .replace(/[{}]/g, "") // remove trailing slash - .replace(/\/+$/, '') + .replace(/\/+$/, "") ) } @@ -177,38 +83,97 @@ function createTranslatedPathnames( } export const translatedPathnames = createTranslatedPathnames({ - '/': { - en: '/', - de: '/', - }, - '/about': { - en: '/about', - de: '/ueber', + "/": { + en: "/", + es: "/" }, + "/about": { + en: "/about", + es: "/nosotros" + }) +``` + +Use in vite.config.ts: + +```ts +paraglideVitePlugin({ + project: './project.inlang', + outdir: './app/paraglide', + urlPatterns: translatedPathnames, }) ``` -And import into the Paraglide Vite plugin. +This guarantees: + +- No missing translations +- Full type safety +- Compiler feedback for routing mistakes + +#### Server Middleware (SSR) + +```ts +import { paraglideMiddleware } from './paraglide/server' + +export default { + fetch(req: Request) { + return paraglideMiddleware(req, () => handler.fetch(req)) + }, +} +``` + +#### HTML Language Attribute + +Set the lang attribute in html at \_\_root.tsx: -## Prerender routes +```tsx +import { getLocale } from '../paraglide/runtime' + +function RootDocument({ children }: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {children} + + + + ) +} +``` -You can use the `localizeHref` function to map the routes to localized versions and import into the pages option in the TanStack Start plugin. For this to work you will need to compile paraglide before the build with the CLI. +#### Prerendering Localized Routes ```ts import { localizeHref } from './paraglide/runtime' + export const prerenderRoutes = ['/', '/about'].map((path) => ({ path: localizeHref(path), + prerender: { enabled: true }, +})) +``` + +In vite.config.ts: + +```ts +tanstackStart({ prerender: { + // Enable prerendering enabled: true, + + // Whether to extract links from the HTML and prerender them also + crawlLinks: true, }, -})) + pages: [ + { + path: '/my-page', + prerender: { enabled: true, outputPath: '/my-page/index.html' }, + }, + ], +}) ``` -## About This Example - -This example demonstrates: +## Looking for i18n with SSR/TanStack Start? -- Multi-language support with Paraglide in TanStack Start -- Server-side translation -- Type-safe translations -- Locale-based routing +Check out the guide on integrating [i18n in TanStack Start](https://tanstack.com/start/latest/docs/framework/react/guide/internationalization-i18n). diff --git a/examples/react/start-i18n-paraglide/vite.config.ts b/examples/react/start-i18n-paraglide/vite.config.ts index a0d8a6f77e1..ca6ad1c24dc 100644 --- a/examples/react/start-i18n-paraglide/vite.config.ts +++ b/examples/react/start-i18n-paraglide/vite.config.ts @@ -17,21 +17,21 @@ const config = defineConfig({ { pattern: '/', localized: [ - ['en', '/en'], + ['en', '/'], ['de', '/de'], ], }, { pattern: '/about', localized: [ - ['en', '/en/about'], + ['en', '/about'], ['de', '/de/ueber'], ], }, { pattern: '/:path(.*)?', localized: [ - ['en', '/en/:path(.*)?'], + ['en', '/:path(.*)?'], ['de', '/de/:path(.*)?'], ], },