Skip to content

Commit b311ebc

Browse files
authored
docs: Sitemaps (#970)
[Preview](https://next-intl-docs-git-docs-sitemap-next-intl.vercel.app/docs/environments/sitemap) **TODO** - [x] Wait for stable Next.js release with support for alternate URLs - [x] Check inilne todos
1 parent c4eac9c commit b311ebc

File tree

27 files changed

+788
-166
lines changed

27 files changed

+788
-166
lines changed

Diff for: docs/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"@vercel/speed-insights": "^1.0.2",
1818
"clsx": "^1.2.1",
1919
"http-status-codes": "^2.2.0",
20-
"next": "^14.1.0",
20+
"next": "^14.2.1",
2121
"nextra": "^2.13.2",
2222
"nextra-theme-docs": "^2.13.2",
2323
"react": "^18.2.0",

Diff for: docs/pages/docs/environments/_meta.json

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"server-client-components": "Server & Client Components",
44
"metadata-route-handlers": "Metadata & Route Handlers",
55
"error-files": "Error files (e.g. not-found)",
6+
"sitemap": "Sitemap",
67
"core-library": "Core library",
78
"runtime-requirements": "Runtime requirements"
89
}

Diff for: docs/pages/docs/environments/sitemap.mdx

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {Tab, Tabs} from 'nextra-theme-docs';
2+
import Callout from 'components/Callout';
3+
4+
# Internationalization of sitemaps
5+
6+
If you're using a sitemap to inform search engines about all pages of your site, you can attach [locale-specific alternate entries](https://developers.google.com/search/docs/specialty/international/localized-versions#sitemap) to every URL in the sitemap to indicate that a particular page is available in multiple languages or regions.
7+
8+
Note that by default, `next-intl` returns [the `link` response header](/docs/routing/middleware#alternate-links) to instruct search engines that a page is available in multiple languages. While this sufficiently links localized pages for search engines, you may choose to provide this information in a sitemap in case you have more specific requirements.
9+
10+
Next.js supports providing alternate URLs per language via the [`alternates` entry](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generate-a-localized-sitemap) as of version 14.2. You can use your default locale for the main URL and provide alternate URLs based on all locales that your app supports. Keep in mind that also the default locale should be included in the `alternates` object.
11+
12+
<Tabs items={['Shared pathnames', 'Localized pathnames']}>
13+
<Tab>
14+
15+
If you're using [shared pathnames](/docs/routing/navigation#shared-pathnames), you can iterate over an array of pathnames that your app supports and generate a sitemap entry for each pathname.
16+
17+
**Example:**
18+
19+
```tsx
20+
import {MetadataRoute} from 'next';
21+
22+
// Can be imported from shared config
23+
const defaultLocale = 'en' as const;
24+
const locales = ['en', 'de'] as const;
25+
26+
// Adapt this as necessary
27+
const pathnames = ['/', '/about'];
28+
const host = 'https://acme.com';
29+
30+
export default function sitemap(): MetadataRoute.Sitemap {
31+
function getUrl(pathname: string, locale: string) {
32+
return `${host}/${locale}${pathname === '/' ? '' : pathname}`;
33+
}
34+
35+
return pathnames.map((pathname) => ({
36+
url: getUrl(pathname, defaultLocale),
37+
lastModified: new Date(),
38+
alternates: {
39+
languages: Object.fromEntries(
40+
locales.map((locale) => [locale, getUrl(pathname, locale)])
41+
)
42+
}
43+
}));
44+
}
45+
```
46+
47+
</Tab>
48+
<Tab>
49+
50+
If you're using [localized pathnames](/docs/routing/navigation#localized-pathnames), you can use the keys of your already declared `pathnames` and generate an entry for each locale via the [`getPathname`](/docs/routing/navigation#getpathname) function.
51+
52+
```tsx
53+
import {MetadataRoute} from 'next';
54+
import {locales, pathnames, defaultLocale} from '@/config';
55+
import {getPathname} from '@/navigation';
56+
57+
// Adapt this as necessary
58+
const host = 'https://acme.com';
59+
60+
export default function sitemap(): MetadataRoute.Sitemap {
61+
const keys = Object.keys(pathnames) as Array<keyof typeof pathnames>;
62+
63+
function getUrl(
64+
key: keyof typeof pathnames,
65+
locale: (typeof locales)[number]
66+
) {
67+
const pathname = getPathname({locale, href: key});
68+
return `${HOST}/${locale}${pathname === '/' ? '' : pathname}`;
69+
}
70+
71+
return keys.map((key) => ({
72+
url: getUrl(key, defaultLocale),
73+
lastModified: new Date(),
74+
alternates: {
75+
languages: Object.fromEntries(
76+
locales.map((locale) => [locale, getUrl(key, locale)])
77+
)
78+
}
79+
}));
80+
}
81+
```
82+
83+
([working implementation](https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/app/sitemap.ts))
84+
85+
</Tab>
86+
</Tabs>
87+
88+
<Callout>
89+
Note that your implementation may vary depending on your routing configuration (e.g. if you're using a [`localePrefix`](/docs/routing/middleware#locale-prefix) other than `always` or [locale-specific domains](/docs/routing/middleware#domain-based-routing)).
90+
</Callout>

Diff for: examples/example-app-router-migration/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"start": "next start"
1111
},
1212
"dependencies": {
13-
"next": "^14.1.0",
13+
"next": "^14.2.1",
1414
"next-intl": "latest",
1515
"react": "^18.2.0",
1616
"react-dom": "^18.2.0"

Diff for: examples/example-app-router-next-auth/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
"start": "next start"
1111
},
1212
"dependencies": {
13-
"next": "^14.1.0",
13+
"next": "^14.2.1",
1414
"next-auth": "^4.24.4",
1515
"next-intl": "latest",
1616
"react": "^18.2.0",
1717
"react-dom": "^18.2.0"
1818
},
1919
"devDependencies": {
20-
"@playwright/test": "^1.40.1",
20+
"@playwright/test": "^1.41.2",
2121
"@types/lodash": "^4.14.176",
2222
"@types/node": "^20.1.2",
2323
"@types/react": "^18.2.29",

Diff for: examples/example-app-router-playground/next.config.mjs

+9-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,12 @@
33
import createNextIntlPlugin from 'next-intl/plugin';
44

55
const withNextIntl = createNextIntlPlugin('./src/i18n.tsx');
6-
export default withNextIntl();
6+
export default withNextIntl({
7+
experimental: {
8+
staleTimes: {
9+
// Next.js 14.2 broke `locale-prefix-never.spec.ts`.
10+
// This is a workaround for the time being.
11+
dynamic: 0
12+
}
13+
}
14+
});

Diff for: examples/example-app-router-playground/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
"dependencies": {
1616
"lodash": "^4.17.21",
1717
"ms": "2.1.3",
18-
"next": "^14.1.0",
18+
"next": "^14.2.1",
1919
"next-intl": "latest",
2020
"react": "^18.2.0",
2121
"react-dom": "^18.2.0"
2222
},
2323
"devDependencies": {
2424
"@jest/globals": "^29.5.0",
25-
"@playwright/test": "^1.40.1",
25+
"@playwright/test": "^1.41.2",
2626
"@testing-library/react": "^13.0.0",
2727
"@types/jest": "^29.5.0",
2828
"@types/lodash": "^4.14.176",

Diff for: examples/example-app-router-playground/tests/locale-prefix-never.spec.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ it('clears the router cache when changing the locale', async ({page}) => {
66
await page.goto('/');
77

88
async function expectDocumentLang(lang: string) {
9-
await expect(page.locator(`html[lang="${lang}"]`)).toBeAttached();
9+
await page.locator(`html[lang="${lang}"]`).waitFor();
10+
}
11+
12+
async function assertCookie(locale: string) {
13+
const cookies = await page.context().cookies();
14+
expect(cookies.find((cookie) => cookie.name === 'NEXT_LOCALE')?.value).toBe(
15+
locale
16+
);
1017
}
1118

1219
await expectDocumentLang('en');
@@ -17,19 +24,22 @@ it('clears the router cache when changing the locale', async ({page}) => {
1724
await expect(
1825
page.getByText('This page hydrates on the client side.')
1926
).toBeAttached();
27+
await assertCookie('en');
2028

2129
await page.getByRole('link', {name: 'Go to home'}).click();
2230
await expectDocumentLang('en');
2331
await expect(page).toHaveURL('/');
32+
await assertCookie('en');
2433

2534
await page.getByRole('link', {name: 'Switch to German'}).click();
26-
2735
await expectDocumentLang('de');
36+
await assertCookie('de');
2837

2938
await page.getByRole('link', {name: 'Client-Seite'}).click();
3039
await expectDocumentLang('de');
3140
await expect(page).toHaveURL('/client');
3241
await expect(
3342
page.getByText('Dise Seite wird auf der Client-Seite initialisiert.')
3443
).toBeAttached();
44+
await assertCookie('de');
3545
});

Diff for: examples/example-app-router/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
},
1414
"dependencies": {
1515
"clsx": "^1.2.1",
16-
"next": "^14.1.0",
16+
"next": "^14.2.1",
1717
"next-intl": "latest",
1818
"react": "^18.2.0",
1919
"react-dom": "^18.2.0",
2020
"tailwindcss": "^3.3.2"
2121
},
2222
"devDependencies": {
2323
"@jest/globals": "^29.5.0",
24-
"@playwright/test": "^1.40.1",
24+
"@playwright/test": "^1.41.2",
2525
"@testing-library/react": "^13.0.0",
2626
"@types/jest": "^29.5.0",
2727
"@types/lodash": "^4.14.176",

Diff for: examples/example-app-router/src/app/[locale]/error.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import {useTranslations} from 'next-intl';
44
import {useEffect} from 'react';
5-
import PageLayout from 'components/PageLayout';
5+
import PageLayout from '@/components/PageLayout';
66

77
type Props = {
88
error: Error;

Diff for: examples/example-app-router/src/app/[locale]/layout.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import clsx from 'clsx';
22
import {Inter} from 'next/font/google';
33
import {getTranslations, unstable_setRequestLocale} from 'next-intl/server';
44
import {ReactNode} from 'react';
5-
import Navigation from 'components/Navigation';
6-
import {locales} from '../../config';
5+
import Navigation from '@/components/Navigation';
6+
import {locales} from '@/config';
77

88
const inter = Inter({subsets: ['latin']});
99

Diff for: examples/example-app-router/src/app/[locale]/not-found.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {useTranslations} from 'next-intl';
2-
import PageLayout from 'components/PageLayout';
2+
import PageLayout from '@/components/PageLayout';
33

44
// Note that `app/[locale]/[...rest]/page.tsx`
55
// is necessary for this page to render.

Diff for: examples/example-app-router/src/app/[locale]/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {useTranslations} from 'next-intl';
22
import {unstable_setRequestLocale} from 'next-intl/server';
3-
import PageLayout from 'components/PageLayout';
3+
import PageLayout from '@/components/PageLayout';
44

55
type Props = {
66
params: {locale: string};

Diff for: examples/example-app-router/src/app/[locale]/pathnames/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {useTranslations} from 'next-intl';
22
import {unstable_setRequestLocale} from 'next-intl/server';
3-
import PageLayout from 'components/PageLayout';
3+
import PageLayout from '@/components/PageLayout';
44

55
type Props = {
66
params: {locale: string};

Diff for: examples/example-app-router/src/app/sitemap.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {MetadataRoute} from 'next';
2+
import {locales, pathnames, defaultLocale, host} from '@/config';
3+
import {getPathname} from '@/navigation';
4+
5+
export default function sitemap(): MetadataRoute.Sitemap {
6+
const keys = Object.keys(pathnames) as Array<keyof typeof pathnames>;
7+
8+
function getUrl(
9+
key: keyof typeof pathnames,
10+
locale: (typeof locales)[number]
11+
) {
12+
const pathname = getPathname({locale, href: key});
13+
return `${host}/${locale}${pathname === '/' ? '' : pathname}`;
14+
}
15+
16+
return keys.map((key) => ({
17+
url: getUrl(key, defaultLocale),
18+
alternates: {
19+
languages: Object.fromEntries(
20+
locales.map((locale) => [locale, getUrl(key, locale)])
21+
)
22+
}
23+
}));
24+
}

Diff for: examples/example-app-router/src/components/LocaleSwitcher.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {useLocale, useTranslations} from 'next-intl';
2-
import {locales} from '../config';
32
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
3+
import {locales} from '@/config';
44

55
export default function LocaleSwitcher() {
66
const t = useTranslations('LocaleSwitcher');

Diff for: examples/example-app-router/src/components/LocaleSwitcherSelect.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import clsx from 'clsx';
44
import {useParams} from 'next/navigation';
55
import {ChangeEvent, ReactNode, useTransition} from 'react';
6-
import {useRouter, usePathname} from '../navigation';
6+
import {useRouter, usePathname} from '@/navigation';
77

88
type Props = {
99
children: ReactNode;

Diff for: examples/example-app-router/src/components/NavigationLink.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import clsx from 'clsx';
44
import {useSelectedLayoutSegment} from 'next/navigation';
55
import {ComponentProps} from 'react';
6-
import type {AppPathnames} from '../config';
7-
import {Link} from '../navigation';
6+
import type {AppPathnames} from '@/config';
7+
import {Link} from '@/navigation';
88

99
export default function NavigationLink<Pathname extends AppPathnames>({
1010
href,

Diff for: examples/example-app-router/src/config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import {Pathnames} from 'next-intl/navigation';
22

3+
export const port = process.env.PORT || 3000;
4+
export const host = process.env.VERCEL_URL
5+
? `https://${process.env.VERCEL_URL}`
6+
: `http://localhost:${port}`;
7+
8+
export const defaultLocale = 'en' as const;
39
export const locales = ['en', 'de'] as const;
410

511
export const pathnames = {

Diff for: examples/example-app-router/src/middleware.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import createMiddleware from 'next-intl/middleware';
2-
import {pathnames, locales, localePrefix} from './config';
2+
import {pathnames, locales, localePrefix, defaultLocale} from './config';
33

44
export default createMiddleware({
5-
defaultLocale: 'en',
5+
defaultLocale,
66
locales,
77
pathnames,
88
localePrefix

Diff for: examples/example-app-router/src/navigation.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
22
import {locales, pathnames, localePrefix} from './config';
33

4-
export const {Link, redirect, usePathname, useRouter} =
4+
export const {Link, getPathname, redirect, usePathname, useRouter} =
55
createLocalizedPathnamesNavigation({
66
locales,
77
pathnames,

Diff for: examples/example-app-router/tests/main.spec.ts

+21
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,24 @@ it('serves a robots.txt', async ({page}) => {
9797
const body = await response?.body();
9898
expect(body?.toString()).toEqual('User-Agent: *\nAllow: *\n');
9999
});
100+
101+
it('serves a sitemap.xml', async ({page}) => {
102+
const response = await page.goto('/sitemap.xml');
103+
const body = await response!.body();
104+
expect(body.toString()).toBe(
105+
`<?xml version="1.0" encoding="UTF-8"?>
106+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
107+
<url>
108+
<loc>http://localhost:3000/en</loc>
109+
<xhtml:link rel="alternate" hreflang="en" href="http://localhost:3000/en" />
110+
<xhtml:link rel="alternate" hreflang="de" href="http://localhost:3000/de" />
111+
</url>
112+
<url>
113+
<loc>http://localhost:3000/en/pathnames</loc>
114+
<xhtml:link rel="alternate" hreflang="en" href="http://localhost:3000/en/pathnames" />
115+
<xhtml:link rel="alternate" hreflang="de" href="http://localhost:3000/de/pfadnamen" />
116+
</url>
117+
</urlset>
118+
`
119+
);
120+
});

Diff for: examples/example-app-router/tsconfig.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"extends": "eslint-config-molindo/tsconfig.json",
33
"compilerOptions": {
4-
"baseUrl": "src",
54
"target": "es5",
65
"lib": [
76
"dom",
@@ -22,7 +21,10 @@
2221
{
2322
"name": "next"
2423
}
25-
]
24+
],
25+
"paths": {
26+
"@/*": ["./src/*"]
27+
}
2628
},
2729
"include": [
2830
"next-env.d.ts",

0 commit comments

Comments
 (0)