i18n: paraglide setup mirroring played/plaza, de unprefixed + /en/ prefixed
Build & Deploy unom website / build (push) Successful in 21s
Build & Deploy unom website / deploy (push) Successful in 4s

- project.inlang/settings.json with locales [de, en], baseLocale de.
- messages/{de,en}.json hold the strings (tagline, blog labels, etc.).
- vite.config.ts: paraglideVitePlugin compiles to src/paraglide/
  (gitignored) on dev/build. Strategy: url → cookie →
  preferredLanguage → baseLocale. URL pattern keeps German at /:path
  (unprefixed) and English at /en/:path so existing URLs stay valid.
- server.tsx wraps the handler in paraglideMiddleware so AsyncLocalStorage
  carries the per-request locale into SSR.
- router.tsx adds rewrite { input: deLocalizeUrl, output: localizeUrl }
  so route matching stays locale-agnostic — the router sees /blog while
  URLs show /en/blog.
- cms.ts narrows getLocale()'s union back to the de|en pair the CMS
  supports, used as the default for find/findGlobal calls.
- Components/routes switch to m.foo() for user-visible strings; date
  formatting picks de-DE / en-GB from getLocale().
- Root html lang reads getLocale() so the document language flips per
  request.

Known minor: TanStack Start's { title } meta entry still serves the base
locale value (og:title and the description meta are localized correctly).
Will track separately.
This commit is contained in:
2026-05-26 19:23:35 +02:00
parent a7ebe77979
commit 7bf10b6a12
14 changed files with 170 additions and 44 deletions
+8 -6
View File
@@ -1,13 +1,15 @@
import { Link } from "@tanstack/react-router";
import type { Post } from "@/lib/cms";
const dateFmt = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
import { getLocale } from "@/paraglide/runtime";
export default function PostCard({ post }: { post: Post }) {
const locale = getLocale();
const dateFmt = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<Link
to="/blog/$slug"
+19 -13
View File
@@ -3,6 +3,7 @@
import { PayloadSDK } from "@payloadcms/sdk";
import type { Config, Page, Post } from "@unom/cms/payload-types";
import { getLocale, type Locale } from "@/paraglide/runtime";
const CMS_URL = "https://cms.unom.io";
@@ -10,52 +11,57 @@ export const cmsClient = new PayloadSDK<Config>({
baseURL: `${CMS_URL}/api`,
});
// The CMS only stores content for the locales declared in its localization
// config; getLocale() on the website can return any locale paraglide knows
// about. Narrow to the set the CMS supports.
function cmsLocale(locale?: Locale): "de" | "en" {
const l = locale ?? getLocale();
return l === "en" ? "en" : "de";
}
export async function findPageBySlug(
slug: string,
locale: "de" | "en" = "de",
locale?: Locale,
): Promise<Page | null> {
const res = await cmsClient.find({
collection: "pages",
where: { slug: { equals: slug } },
limit: 1,
locale,
locale: cmsLocale(locale),
depth: 1,
});
return res.docs[0] ?? null;
}
export async function findPosts(
locale: "de" | "en" = "de",
limit = 50,
) {
export async function findPosts(locale?: Locale, limit = 50) {
return cmsClient.find({
collection: "posts",
limit,
sort: "-createdAt",
locale,
locale: cmsLocale(locale),
});
}
export async function findPostBySlug(
slug: string,
locale: "de" | "en" = "de",
locale?: Locale,
): Promise<Post | null> {
const res = await cmsClient.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
locale,
locale: cmsLocale(locale),
depth: 1,
});
return res.docs[0] ?? null;
}
export async function findFooter(locale: "de" | "en" = "de") {
return cmsClient.findGlobal({ slug: "footer", locale });
export async function findFooter(locale?: Locale) {
return cmsClient.findGlobal({ slug: "footer", locale: cmsLocale(locale) });
}
export async function findHeader(locale: "de" | "en" = "de") {
return cmsClient.findGlobal({ slug: "header", locale });
export async function findHeader(locale?: Locale) {
return cmsClient.findGlobal({ slug: "header", locale: cmsLocale(locale) });
}
export type { Page, Post, Footer, Header } from "@unom/cms/payload-types";
+8
View File
@@ -1,4 +1,5 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { deLocalizeUrl, localizeUrl } from "./paraglide/runtime.js";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
@@ -6,6 +7,13 @@ export function getRouter() {
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
// Keep route matching locale-agnostic: the router sees `/blog`, not
// `/en/blog`. On every navigation paraglide writes the locale prefix
// back into the URL according to the current locale.
rewrite: {
input: ({ url }) => deLocalizeUrl(url),
output: ({ url }) => localizeUrl(url),
},
});
return router;
+7 -8
View File
@@ -7,6 +7,8 @@ import {
import "@fontsource/inter";
import Layout from "@/components/Layout";
import { findFooter, type Footer } from "@/lib/cms";
import { m } from "@/paraglide/messages";
import { getLocale } from "@/paraglide/runtime";
import appCss from "../styles/globals.css?url";
const SITE_URL = "https://unom.io";
@@ -26,13 +28,10 @@ export const Route = createRootRoute({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "description", content: "Kreative Webentwicklung aus Rottweil" },
{ title: "unom - Kreative Webentwicklung" },
{ property: "og:title", content: "unom - Kreative Webentwicklung" },
{
property: "og:description",
content: "Kreative Webentwicklung aus Rottweil",
},
{ name: "description", content: m.site_description() },
{ title: m.home_title() },
{ property: "og:title", content: m.home_title() },
{ property: "og:description", content: m.site_description() },
{ property: "og:url", content: SITE_URL },
{ property: "og:type", content: "website" },
],
@@ -53,7 +52,7 @@ export const Route = createRootRoute({
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<html lang={getLocale()} suppressHydrationWarning>
<head>
<HeadContent />
</head>
+5 -7
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import PostCard from "@/components/PostCard";
import Section from "@/components/Section";
import { findPosts } from "@/lib/cms";
import { m } from "@/paraglide/messages";
export const Route = createFileRoute("/blog/")({
loader: async () => {
@@ -11,11 +12,8 @@ export const Route = createFileRoute("/blog/")({
component: BlogIndex,
head: () => ({
meta: [
{ title: "unom - Blog" },
{
name: "description",
content: "Notizen, Gedanken und Tutorials aus der Werkstatt von unom.",
},
{ title: `${m.site_name()} - ${m.blog_title()}` },
{ name: "description", content: m.blog_meta_description() },
],
}),
});
@@ -25,9 +23,9 @@ function BlogIndex() {
return (
<Section className="pt-height-header">
<h1>Blog</h1>
<h1>{m.blog_title()}</h1>
{posts.length === 0 ? (
<p>Noch keine Beiträge.</p>
<p>{m.blog_empty()}</p>
) : (
<div className="grid gap-6 md:grid-cols-2 mt-6">
{posts.map((post) => (
+2 -3
View File
@@ -1,5 +1,6 @@
import LogoQuadBG from "@/components/LogoQuadBG";
import bgDark from "@/assets/unom_Logo_5_Dark.webp";
import { m } from "@/paraglide/messages";
export default function Landing() {
return (
@@ -11,7 +12,6 @@ export default function Landing() {
height={2160}
alt="Ein 3D Rendering des unom Logos"
/>
{/* dim layer so the tagline sits on a calmer field */}
<div className="absolute inset-0 bg-gradient-to-b from-neutral/0 via-neutral/0 to-neutral/80" />
<div className="relative h-full flex flex-col items-center justify-center gap-10 px-section-main-x text-center">
@@ -19,11 +19,10 @@ export default function Landing() {
<LogoQuadBG />
</div>
<p className="max-w-md text-lg md:text-xl text-main/90">
Kreative Webentwicklung aus Rottweil.
{m.site_tagline()}
</p>
</div>
{/* subtle scroll hint */}
<div
aria-hidden
className="absolute bottom-6 left-1/2 -translate-x-1/2 text-secondary/60 text-sm tracking-wide"
+5 -1
View File
@@ -1,7 +1,11 @@
import handler from "@tanstack/react-start/server-entry";
import { paraglideMiddleware } from "./paraglide/server.js";
export default {
async fetch(req: Request): Promise<Response> {
return handler.fetch(req);
// paraglide reads the URL prefix / cookie / Accept-Language and binds the
// detected locale into AsyncLocalStorage for the duration of the request,
// so any `getLocale()` calls during SSR see the right value.
return paraglideMiddleware(req, () => handler.fetch(req));
},
};