feat: implement internationalization with next-intl

- Add multi-language support using next-intl
- Configure routing and localization for English and Chinese
- Update project structure to support i18n routing
- Add middleware and navigation helpers for localized routing
- Create message files for translations
- Modify layout and page components to support internationalization
This commit is contained in:
javayhu 2025-03-01 21:59:22 +08:00
parent 24eb819a85
commit 4c6997a012
35 changed files with 454 additions and 161 deletions

12
messages/cn.json Normal file
View File

@ -0,0 +1,12 @@
{
"HomePage": {
"title": "你好,世界!",
"about": "去关于页面"
},
"AboutPage": {
"title": "关于"
},
"BlogPage": {
"title": "博客"
}
}

12
messages/en.json Normal file
View File

@ -0,0 +1,12 @@
{
"HomePage": {
"title": "Hello world!",
"about": "Go to the about page"
},
"AboutPage": {
"title": "About"
},
"BlogPage": {
"title": "Blog"
}
}

View File

@ -1,6 +1,15 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
import { withContentCollections } from "@content-collections/next";
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#next-config
*/
const withNextIntl = createNextIntlPlugin();
/**
* https://nextjs.org/docs/app/api-reference/config/next-config-js
*/
const nextConfig: NextConfig = {
/* config options here */
@ -13,11 +22,13 @@ const nextConfig: NextConfig = {
images: {
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
// vercel has limits on image optimization, 1000 images per month
unoptimized: true,
// unoptimized: true,
// https://medium.com/@niniroula/nextjs-upgrade-next-image-and-dangerouslyallowsvg-c934060d79f8
// The requested resource "https://cdn.sanity.io/images/58a2mkbj/preview/xxx.svg?fit=max&auto=format" has type "image/svg+xml"
// but dangerouslyAllowSVG is disabled
dangerouslyAllowSVG: true,
// dangerouslyAllowSVG: true,
remotePatterns: [
{
protocol: "https",
@ -31,18 +42,13 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "randomuser.me",
},
{
protocol: "https",
hostname: "cdn.sanity.io", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
},
{
protocol: "https",
hostname: "via.placeholder.com", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
},
],
},
};
// https://www.content-collections.dev/docs/quickstart/next
// withContentCollections must be the outermost plugin
export default withContentCollections(nextConfig);
/**
* withContentCollections must be the outermost plugin
*
* https://www.content-collections.dev/docs/quickstart/next
*/
export default withContentCollections(withNextIntl(nextConfig));

View File

@ -42,6 +42,7 @@
"mdast-util-toc": "^7.1.0",
"motion": "^12.4.3",
"next": "15.1.7",
"next-intl": "^3.26.5",
"next-plausible": "^3.12.4",
"next-safe-action": "^7.10.4",
"next-themes": "^0.4.4",

97
pnpm-lock.yaml generated
View File

@ -107,6 +107,9 @@ importers:
next:
specifier: 15.1.7
version: 15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-intl:
specifier: ^3.26.5
version: 3.26.5(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
next-plausible:
specifier: ^3.12.4
version: 3.12.4(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -976,6 +979,24 @@ packages:
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@formatjs/ecma402-abstract@2.3.3':
resolution: {integrity: sha512-pJT1OkhplSmvvr6i3CWTPvC/FGC06MbN5TNBfRO6Ox62AEz90eMq+dVvtX9Bl3jxCEkS0tATzDarRZuOLw7oFg==}
'@formatjs/fast-memoize@2.2.6':
resolution: {integrity: sha512-luIXeE2LJbQnnzotY1f2U2m7xuQNj2DA8Vq4ce1BY9ebRZaoPB1+8eZ6nXpLzsxuW5spQxr7LdCg+CApZwkqkw==}
'@formatjs/icu-messageformat-parser@2.11.1':
resolution: {integrity: sha512-o0AhSNaOfKoic0Sn1GkFCK4MxdRsw7mPJ5/rBpIqdvcC7MIuyUSW8WChUEvrK78HhNpYOgqCQbINxCTumJLzZA==}
'@formatjs/icu-skeleton-parser@1.8.13':
resolution: {integrity: sha512-N/LIdTvVc1TpJmMt2jVg0Fr1F7Q1qJPdZSCs19unMskCmVQ/sa0H9L8PWt13vq+gLdLg1+pPsvBLydL1Apahjg==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.0':
resolution: {integrity: sha512-4rB4g+3hESy1bHSBG3tDFaMY2CH67iT7yne1e+0CLTsGLDcmoEWWpJjjpWVaYgYfYuohIRuo0E+N536gd2ZHZA==}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
@ -2063,6 +2084,9 @@ packages:
supports-color:
optional: true
decimal.js@10.5.0:
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
@ -2494,6 +2518,9 @@ packages:
inline-style-parser@0.2.4:
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
intl-messageformat@10.7.15:
resolution: {integrity: sha512-LRyExsEsefQSBjU2p47oAheoKz+EOJxSLDdjOaEjdriajfHsMXOmV/EhMvYSg9bAgCUHasuAC+mcUBe/95PfIg==}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@ -2855,6 +2882,16 @@ packages:
resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
engines: {node: ^18.0.0 || >=20.0.0}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-intl@3.26.5:
resolution: {integrity: sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==}
peerDependencies:
next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
next-plausible@3.12.4:
resolution: {integrity: sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg==}
peerDependencies:
@ -3595,6 +3632,11 @@ packages:
'@types/react':
optional: true
use-intl@3.26.5:
resolution: {integrity: sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
@ -4183,6 +4225,36 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
'@formatjs/ecma402-abstract@2.3.3':
dependencies:
'@formatjs/fast-memoize': 2.2.6
'@formatjs/intl-localematcher': 0.6.0
decimal.js: 10.5.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.6':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.1':
dependencies:
'@formatjs/ecma402-abstract': 2.3.3
'@formatjs/icu-skeleton-parser': 1.8.13
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.13':
dependencies:
'@formatjs/ecma402-abstract': 2.3.3
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.0':
dependencies:
tslib: 2.8.1
'@hexagon/base64@1.1.28': {}
'@hookform/resolvers@4.1.0(react-hook-form@7.54.2(react@19.0.0))':
@ -5262,6 +5334,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js@10.5.0: {}
decode-named-character-reference@1.0.2:
dependencies:
character-entities: 2.0.2
@ -5775,6 +5849,13 @@ snapshots:
inline-style-parser@0.2.4: {}
intl-messageformat@10.7.15:
dependencies:
'@formatjs/ecma402-abstract': 2.3.3
'@formatjs/fast-memoize': 2.2.6
'@formatjs/icu-messageformat-parser': 2.11.1
tslib: 2.8.1
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@ -6396,6 +6477,16 @@ snapshots:
nanostores@0.11.4: {}
negotiator@1.0.0: {}
next-intl@3.26.5(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
use-intl: 3.26.5(react@19.0.0)
next-plausible@3.12.4(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
next: 15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -7249,6 +7340,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.9
use-intl@3.26.5(react@19.0.0):
dependencies:
'@formatjs/fast-memoize': 2.2.6
intl-messageformat: 10.7.15
react: 19.0.0
use-sidecar@1.1.3(@types/react@19.0.9)(react@19.0.0):
dependencies:
detect-node-es: 1.1.0

View File

@ -1,94 +0,0 @@
// import PromotekitScript from "@/components/affiliate/promotekit-stripe-checkout";
// import { HomeCallToAction } from "@/components/home/home-cta";
// import { HomeFAQ } from "@/components/home/home-faq";
// import HomeFeatures from "@/components/home/home-features";
// import { HomeFeaturesHeader } from "@/components/home/home-features-header";
// import { HomeFeaturesMore } from "@/components/home/home-features-more";
// import HomeHero from "@/components/home/home-hero";
// import { HomeHowItWorks } from "@/components/home/home-how-it-works";
// import { HomeIntroduction } from "@/components/home/home-introduction";
// import HomeMonetization from "@/components/home/home-monetization";
// import { HomeNewsletter } from "@/components/home/home-newsletter";
// import HomePowered from "@/components/home/home-powered";
// import HomePricing from "@/components/home/home-pricing";
// import { HomeShowcase } from "@/components/home/home-showcase";
// import { HomeTestimonials } from "@/components/home/home-testimonials";
// import HomeVideo from "@/components/home/home-video";
import CallToAction from "@/components/nsui/call-to-action";
import ContentSection from "@/components/nsui/content-2";
import FAQs from "@/components/nsui/faqs";
import Features from "@/components/nsui/features-2";
import FeaturesSection from "@/components/nsui/features-8";
import HeroSection from "@/components/nsui/hero-section";
import LogoCloud from "@/components/nsui/logo-cloud";
import Pricing from "@/components/nsui/pricing";
import StatsSection from "@/components/nsui/stats";
import WallOfLoveSection from "@/components/nsui/testimonials";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "",
canonicalUrl: `${siteConfig.url}/`,
});
export default async function HomePage() {
return (
<>
{/* <PromotekitScript /> */}
<div className="mt-12 flex flex-col gap-16">
<HeroSection />
<LogoCloud />
{/* <Features /> */}
<FeaturesSection />
<ContentSection />
<Pricing />
<FAQs />
<WallOfLoveSection />
<StatsSection />
<CallToAction />
{/* <HomeHero /> */}
{/* <HomeVideo /> */}
{/* <HomePowered /> */}
{/* <HomeMonetization /> */}
{/* <HomeHowItWorks /> */}
{/* <HomeFeaturesHeader /> */}
{/* <HomeFeatures /> */}
{/* <HomeFeaturesMore /> */}
{/* <HomePricing /> */}
{/* <HomeFAQ /> */}
{/* <HomeIntroduction /> */}
{/* <HomeTestimonials /> */}
{/* <HomeCallToAction /> */}
{/* <HomeShowcase /> */}
{/* <HomeNewsletter /> */}
</div>
</>
);
}

View File

@ -0,0 +1,65 @@
import CallToAction from "@/components/nsui/call-to-action";
import ContentSection from "@/components/nsui/content-2";
import FAQs from "@/components/nsui/faqs";
import FeaturesSection from "@/components/nsui/features-8";
import HeroSection from "@/components/nsui/hero-section";
import LogoCloud from "@/components/nsui/logo-cloud";
import Pricing from "@/components/nsui/pricing";
import StatsSection from "@/components/nsui/stats";
import WallOfLoveSection from "@/components/nsui/testimonials";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
import { getTranslations, setRequestLocale } from 'next-intl/server';
export const metadata = constructMetadata({
title: "",
canonicalUrl: `${siteConfig.url}/`,
});
interface HomePageProps {
params: { locale: string };
};
export default async function HomePage({ params }: HomePageProps) {
const { locale } = params;
// Enable static rendering
setRequestLocale(locale);
// Use getTranslations instead of useTranslations for async server components
const t = await getTranslations('HomePage');
return (
<>
{/* <PromotekitScript /> */}
<div className="mt-12 flex flex-col gap-16">
<div>
<h1>{t('title')}</h1>
{/* <Link href="/about">{t('about')}</Link> */}
</div>
<HeroSection />
<LogoCloud />
{/* <Features /> */}
<FeaturesSection />
<ContentSection />
<Pricing />
<FAQs />
<WallOfLoveSection />
<StatsSection />
<CallToAction />
</div>
</>
);
}

View File

@ -0,0 +1,20 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
interface AboutPageProps {
params: { locale: string };
};
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = params;
// Enable static rendering
setRequestLocale(locale);
const t = await getTranslations('AboutPage');
return (
<div>
<h1>{t('title')}</h1>
</div>
);
}

View File

@ -1,18 +1,25 @@
import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/marketing/navbar";
import { marketingConfig } from "@/config/marketing";
import { setRequestLocale } from "next-intl/server";
interface MarketingLayoutProps {
children: React.ReactNode;
params: { locale: string };
}
export default async function MarketingLayout({
children,
params
}: MarketingLayoutProps) {
const { locale } = params;
// Enable static rendering
setRequestLocale(locale);
return (
<div className="flex flex-col min-h-screen">
{/* <Navbar scroll={true} config={marketingConfig} /> */}
<Navbar scroll={false} config={marketingConfig} />
<Navbar scroll={true} config={marketingConfig} />
<main className="flex-1">{children}</main>

View File

@ -0,0 +1,5 @@
import { notFound } from 'next/navigation';
export default function CatchAllPage() {
notFound();
}

View File

@ -0,0 +1,82 @@
import "@/app/globals.css";
import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts";
import { Toaster } from "@/components/ui/sonner";
import { routing } from '@/i18n/routing';
import { constructMetadata } from "@/lib/metadata";
import { cn } from "@/lib/utils";
import { GeistMono } from "geist/font/mono";
import { GeistSans } from "geist/font/sans";
import type { Metadata, Viewport } from "next";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { Providers } from "./providers";
import { TailwindIndicator } from "@/components/tailwind-indicator";
export const metadata: Metadata = constructMetadata();
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
minimumScale: 1,
maximumScale: 1,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' }
]
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
interface LocaleLayoutProps {
children: ReactNode;
params: { locale: string };
};
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#layout
*/
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = params;
// Ensure that the incoming `locale` is valid
if (!routing.locales.includes(locale as any)) {
notFound();
}
// Enable static rendering
setRequestLocale(locale);
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
// TODO: body min-h-screen -> size-full, check if this is correct on mobile
return (
<html lang={locale} suppressHydrationWarning>
<body
className={cn(
"size-full antialiased",
GeistSans.className,
fontSourceSerif4.variable,
fontSourceSans.variable,
GeistSans.variable,
GeistMono.variable,
)}
>
<NextIntlClientProvider messages={messages}>
<Providers>
{children}
<Toaster richColors position="top-right" offset={64} />
<TailwindIndicator />
</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
export function Providers({
children
}: React.PropsWithChildren) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<TooltipProvider>
{children}
</TooltipProvider>
</ThemeProvider>
);
}

View File

@ -1,55 +1,8 @@
import "@/app/globals.css";
import type { Metadata, Viewport } from "next";
import type { PropsWithChildren } from "react";
import { constructMetadata } from "@/lib/metadata";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { TailwindIndicator } from "@/components/tailwind-indicator";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts";
export const metadata: Metadata = constructMetadata();
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
minimumScale: 1,
maximumScale: 1,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' }
]
};
import { PropsWithChildren } from 'react';
// Since we have a `not-found.tsx` page on the root, a layout file
// is required, even if it's just passing children through.
export default function RootLayout({ children }: PropsWithChildren) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body
className={cn(
"min-h-screen bg-background antialiased",
GeistSans.className,
fontSourceSerif4.variable,
fontSourceSans.variable,
GeistSans.variable,
GeistMono.variable,
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster richColors position="top-right" offset={64} />
{/* <TailwindIndicator /> */}
</ThemeProvider>
</body>
</html>
);
// Simply pass children through - the [locale] layout will handle the HTML structure
return children;
}

View File

@ -4,6 +4,8 @@ import { siteConfig } from '@/config/site';
/**
* Generates the Web App Manifest for the application
*
* TODO: https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/app/manifest.ts
*
* The manifest.json provides metadata used when the web app is installed on a
* user's mobile device or desktop. See https://web.dev/add-manifest/
*

6
src/app/page.tsx Normal file
View File

@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
// This page only renders when the app is built statically (output: 'export')
export default function RootPage() {
redirect('/en');
}

8
src/i18n/navigation.ts Normal file
View File

@ -0,0 +1,8 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from '@/i18n/routing';
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-navigation
*/
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

22
src/i18n/request.ts Normal file
View File

@ -0,0 +1,22 @@
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;
// Ensure that the incoming `locale` is valid
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (
await (locale === 'en'
? // When using Turbopack, this will enable HMR for `en`
import('../../messages/en.json')
: import(`../../messages/${locale}.json`))
).default
};
});

19
src/i18n/routing.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineRouting } from 'next-intl/routing';
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-routing
*/
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'cn'],
// Used when no locale matches
defaultLocale: 'en',
pathnames: {
'/': '/'
}
});
export type Pathnames = keyof typeof routing.pathnames;
export type Locale = (typeof routing.locales)[number];

19
src/middleware.ts Normal file
View File

@ -0,0 +1,19 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from '@/i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: [
// Enable a redirect to a matching locale at the root
'/',
// Set a cookie to remember the previous locale for
// all requests that have a locale prefix
'/(cn|en)/:path*',
// Enable redirects that add missing locales
// (e.g. `/pathnames` -> `/en/pathnames`)
'/((?!_next|_vercel|.*\\..*).*)'
]
};

29
src/sitemap.ts Normal file
View File

@ -0,0 +1,29 @@
import { MetadataRoute } from 'next';
import { routing, Locale } from '@/i18n/routing';
import { getPathname } from '@/i18n/navigation';
import { siteConfig } from './config/site';
export default function sitemap(): MetadataRoute.Sitemap {
return [...getEntries('/')];
}
type Href = Parameters<typeof getPathname>[0]['href'];
/**
* https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/app/sitemap.ts
*/
function getEntries(href: Href) {
return routing.locales.map((locale) => ({
url: getUrl(href, locale),
alternates: {
languages: Object.fromEntries(
routing.locales.map((cur) => [cur, getUrl(href, cur)])
)
}
}));
}
function getUrl(href: Href, locale: Locale) {
const pathname = getPathname({ locale, href });
return siteConfig.url + pathname;
}