From 5b135ce712f02daae4798211d07fffac54a0257a Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Fri, 30 Oct 2026 22:13:57 +0100 Subject: [PATCH] Define locales once with next-intl's routing config Locales were listed separately in the middleware, the request config and several type casts. Use defineRouting and createNavigation, type the Locale globally, and let the language selector use the locale-aware router so it keeps the query string and hash. Co-Authored-By: Claude Opus 5.5 --- next.config.ts | 2 +- src/app/[locale]/blog/[slug]/page.tsx | 3 ++- src/app/[locale]/event/[slug]/page.tsx | 3 ++- src/app/[locale]/layout.tsx | 6 +++--- src/app/[locale]/page.tsx | 3 ++- src/components/page-sections/boardMembers.tsx | 2 +- src/components/page-sections/notFound.tsx | 3 ++- src/components/seo.tsx | 3 ++- src/components/ui/languageSelector.tsx | 15 +++++++++------ src/i18n/navigation.ts | 7 +++++++ src/{i18n.ts => i18n/request.ts} | 9 +++++---- src/i18n/routing.ts | 14 ++++++++++++++ src/middleware.ts | 8 +++----- 13 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 src/i18n/navigation.ts rename src/{i18n.ts => i18n/request.ts} (69%) create mode 100644 src/i18n/routing.ts diff --git a/next.config.ts b/next.config.ts index 0fdc31e..66e7bc6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next" import createNextIntlPlugin from "next-intl/plugin" -const withNextIntl = createNextIntlPlugin("./src/i18n.ts") +const withNextIntl = createNextIntlPlugin() const nextConfig: NextConfig = { images: { diff --git a/src/app/[locale]/blog/[slug]/page.tsx b/src/app/[locale]/blog/[slug]/page.tsx index a570b3b..e1593c7 100644 --- a/src/app/[locale]/blog/[slug]/page.tsx +++ b/src/app/[locale]/blog/[slug]/page.tsx @@ -7,6 +7,7 @@ import { getBlogPost } from "@/lib/sanity" import { PortableText, PortableTextComponents } from "@portabletext/react" import { Metadata, Viewport } from "next" +import type { Locale } from "next-intl" import { notFound } from "next/navigation" import Image from "next/image" @@ -15,7 +16,7 @@ import * as styles from "./page.module.css" interface BlogPageParams { params: Promise<{ slug: string - locale: string + locale: Locale }> } diff --git a/src/app/[locale]/event/[slug]/page.tsx b/src/app/[locale]/event/[slug]/page.tsx index dbf3f44..03de1fd 100644 --- a/src/app/[locale]/event/[slug]/page.tsx +++ b/src/app/[locale]/event/[slug]/page.tsx @@ -4,6 +4,7 @@ import Back from "@/components/ui/back" import { getEvent } from "@/lib/sanity" import { Metadata, Viewport } from "next" +import type { Locale } from "next-intl" import { notFound } from "next/navigation" import { getTranslations } from "next-intl/server" import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa" @@ -13,7 +14,7 @@ import * as styles from "./page.module.css" interface EventPageParams { params: Promise<{ slug: string - locale: string + locale: Locale }> } diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index d514566..cabe627 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -6,7 +6,7 @@ import { notFound } from "next/navigation" import LanguageSelector from "@components/ui/languageSelector" import UmamiAnalytics from "@/components/umamiAnalytics" -import { locales } from "@/i18n" +import { routing } from "@/i18n/routing" import { getAnalyticsConfig } from "@/lib/analyticsConfig" export const metadata: Metadata = { @@ -21,7 +21,7 @@ export const metadata: Metadata = { } export function generateStaticParams() { - return locales.map(locale => ({ locale })) + return routing.locales.map(locale => ({ locale })) } export default async function RootLayout({ @@ -34,7 +34,7 @@ export default async function RootLayout({ const { locale } = await params // [locale] matches any first path segment, e.g. /favicon.txt or /de - if (!hasLocale(locales, locale)) notFound() + if (!hasLocale(routing.locales, locale)) notFound() const messages = (await import(`../../../messages/${locale}.json`)).default diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 8c89850..7acbd22 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -8,12 +8,13 @@ import Office from "@/components/page-sections/office" import { generateSeoMetadata, generateSeoViewport } from "@/components/seo" import { Metadata, Viewport } from "next" +import type { Locale } from "next-intl" export const revalidate = 3600 // 1 hour interface HomePageParams { params: Promise<{ - locale: string + locale: Locale }> } diff --git a/src/components/page-sections/boardMembers.tsx b/src/components/page-sections/boardMembers.tsx index 3ea5fdf..07c767b 100644 --- a/src/components/page-sections/boardMembers.tsx +++ b/src/components/page-sections/boardMembers.tsx @@ -29,7 +29,7 @@ interface BoardMember { const BoardMembers = async () => { const t = await getTranslations("boardMembers") - const locale = (await getLocale()) as "no" | "en" + const locale = await getLocale() const data: BoardMember[] = await getBoardMembers() diff --git a/src/components/page-sections/notFound.tsx b/src/components/page-sections/notFound.tsx index 7359f80..fb42e9c 100644 --- a/src/components/page-sections/notFound.tsx +++ b/src/components/page-sections/notFound.tsx @@ -1,9 +1,10 @@ +import type { Locale } from "next-intl" import { getTranslations } from "next-intl/server" import Link from "next/link" import * as styles from "./notFound.module.css" -const NotFound = async ({ locale }: { locale: string }) => { +const NotFound = async ({ locale }: { locale: Locale }) => { const t = await getTranslations({ locale, namespace: "404" }) return ( diff --git a/src/components/seo.tsx b/src/components/seo.tsx index c865384..2f30c75 100644 --- a/src/components/seo.tsx +++ b/src/components/seo.tsx @@ -1,8 +1,9 @@ import { Metadata, Viewport } from "next" +import type { Locale } from "next-intl" import { getTranslations } from "next-intl/server" type SeoProps = { - locale: string + locale: Locale title?: string description?: string imageURL?: string diff --git a/src/components/ui/languageSelector.tsx b/src/components/ui/languageSelector.tsx index 6212f27..c9634e6 100644 --- a/src/components/ui/languageSelector.tsx +++ b/src/components/ui/languageSelector.tsx @@ -1,20 +1,23 @@ "use client" import { GB, NO } from "country-flag-icons/react/3x2" -import { useLocale } from "next-intl" -import { useRouter, usePathname } from "next/navigation" +import { type Locale, useLocale } from "next-intl" + +import { usePathname, useRouter } from "@/i18n/navigation" import * as styles from "./languageSelector.module.css" const LanguageSelector = () => { const locale = useLocale() const router = useRouter() + // Pathname without the locale prefix const pathname = usePathname() - const changeLocale = (newLocale: "no" | "en") => { - // Remove the current locale from the pathname - const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}/, "") - router.push(`/${newLocale}${pathWithoutLocale}`) + const changeLocale = (newLocale: Locale) => { + // Read at click time, since useSearchParams() would opt pages out of + // static rendering + const { search, hash } = window.location + router.push(`${pathname}${search}${hash}`, { locale: newLocale }) } return ( diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts new file mode 100644 index 0000000..c6642ca --- /dev/null +++ b/src/i18n/navigation.ts @@ -0,0 +1,7 @@ +import { createNavigation } from "next-intl/navigation" + +import { routing } from "./routing" + +// Locale-aware wrappers around Next's navigation APIs +export const { Link, redirect, usePathname, useRouter, getPathname } = + createNavigation(routing) diff --git a/src/i18n.ts b/src/i18n/request.ts similarity index 69% rename from src/i18n.ts rename to src/i18n/request.ts index 50b056c..79aa093 100644 --- a/src/i18n.ts +++ b/src/i18n/request.ts @@ -2,18 +2,19 @@ import { hasLocale } from "next-intl" import { getRequestConfig } from "next-intl/server" import { locale as rootLocale } from "next/root-params" -export const locales = ["no", "en"] as const -const defaultLocale = "no" +import { routing } from "./routing" export default getRequestConfig(async ({ locale }) => { // An explicit locale (e.g. getTranslations({ locale })) takes precedence. // The root param can be any first path segment until the layout rejects it, // so fall back to the default instead of throwing const requested = locale ?? (await rootLocale()) - const resolved = hasLocale(locales, requested) ? requested : defaultLocale + const resolved = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale return { locale: resolved, - messages: (await import(`../messages/${resolved}.json`)).default, + messages: (await import(`../../messages/${resolved}.json`)).default, } }) diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts new file mode 100644 index 0000000..9cb047d --- /dev/null +++ b/src/i18n/routing.ts @@ -0,0 +1,14 @@ +import { defineRouting } from "next-intl/routing" + +export const routing = defineRouting({ + locales: ["no", "en"], + defaultLocale: "no", + localePrefix: "always", +}) + +// Types getLocale(), useLocale() etc. as "no" | "en" instead of string +declare module "next-intl" { + interface AppConfig { + Locale: (typeof routing.locales)[number] + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 2ce9fdc..cd0ca9e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,10 +1,8 @@ import createMiddleware from "next-intl/middleware" -export default createMiddleware({ - locales: ["no", "en"], - defaultLocale: "no", - localePrefix: "always", -}) +import { routing } from "./i18n/routing" + +export default createMiddleware(routing) export const config = { // Match everything except API routes, Next internals and files with an