Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,54 @@ Output:
</urlset>
```

### Video Sitemaps

You can use `videos` property to create video sitemaps. Learn more details in the [Google Developer Docs](https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps).

```ts filename="app/sitemap.ts" switcher
import type { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com',
lastModified: '2021-01-01',
changeFrequency: 'weekly',
priority: 0.5,
videos: [
{
title: 'example',
thumbnail_loc: 'https://example.com/image.jpg',
description: 'this is the description',
},
],
},
]
}
```

Output:

```xml filename="acme.com/sitemap.xml"
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
>
<url>
<loc>https://example.com</loc>
<video:video>
<video:title>example</video:title>
<video:thumbnail_loc>https://example.com/image.jpg</video:thumbnail_loc>
<video:description>this is the description</video:description>
</video:video>
<lastmod>2021-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
</urlset>
```

### Generate a localized Sitemap

```ts filename="app/sitemap.ts" switcher
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,5 +153,77 @@ describe('resolveRouteData', () => {
"
`)
})
it('should resolve sitemap.xml with videos', () => {
expect(
resolveSitemap([
{
url: 'https://example.com',
lastModified: '2021-01-01',
changeFrequency: 'weekly',
priority: 0.5,
videos: [
{
title: 'example',
thumbnail_loc: 'https://example.com/image.jpg',
description: 'this is the description',
content_loc: 'http://streamserver.example.com/video123.mp4',
player_loc: 'https://www.example.com/videoplayer.php?video=123',
duration: 2,
view_count: 50,
tag: 'summer',
rating: 4,
expiration_date: '2025-09-16',
publication_date: '2024-09-16',
family_friendly: 'yes',
requires_subscription: 'no',
live: 'no',
restriction: {
relationship: 'allow',
content: 'IE GB US CA',
},
platform: {
relationship: 'allow',
content: 'web',
},
uploader: {
info: 'https://www.example.com/users/grillymcgrillerson',
content: 'GrillyMcGrillerson',
},
},
],
},
])
).toMatchInlineSnapshot(`
"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com</loc>
<video:video>
<video:title>example</video:title>
<video:thumbnail_loc>https://example.com/image.jpg</video:thumbnail_loc>
<video:description>this is the description</video:description>
<video:content_loc>http://streamserver.example.com/video123.mp4</video:content_loc>
<video:player_loc>https://www.example.com/videoplayer.php?video=123</video:player_loc>
<video:duration>2</video:duration>
<video:view_count>50</video:view_count>
<video:tag>summer</video:tag>
<video:rating>4</video:rating>
<video:expiration_date>2025-09-16</video:expiration_date>
<video:publication_date>2024-09-16</video:publication_date>
<video:family_friendly>yes</video:family_friendly>
<video:requires_subscription>no</video:requires_subscription>
<video:live>no</video:live>
<video:restriction relationship="allow">IE GB US CA</video:restriction>
<video:platform relationship="allow">web</video:platform>
<video:uploader info="https://www.example.com/users/grillymcgrillerson">GrillyMcGrillerson</video:uploader>
</video:video>
<lastmod>2021-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
</urlset>
"
`)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ export function resolveSitemap(data: MetadataRoute.Sitemap): string {
(item) => Object.keys(item.alternates ?? {}).length > 0
)
const hasImages = data.some((item) => Boolean(item.images?.length))
const hasVideos = data.some((item) => Boolean(item.videos?.length))

let content = ''
content += '<?xml version="1.0" encoding="UTF-8"?>\n'
content += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'
if (hasImages) {
content += ' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"'
}
if (hasVideos) {
content += ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'
}
if (hasAlternates) {
content += ' xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
} else {
Expand All @@ -79,6 +83,43 @@ export function resolveSitemap(data: MetadataRoute.Sitemap): string {
content += `<image:image>\n<image:loc>${image}</image:loc>\n</image:image>\n`
}
}
if (item.videos?.length) {
for (const video of item.videos) {
let videoFields = [
`<video:video>`,
`<video:title>${video.title}</video:title>`,
`<video:thumbnail_loc>${video.thumbnail_loc}</video:thumbnail_loc>`,
`<video:description>${video.description}</video:description>`,
video.content_loc &&
`<video:content_loc>${video.content_loc}</video:content_loc>`,
video.player_loc &&
`<video:player_loc>${video.player_loc}</video:player_loc>`,
video.duration &&
`<video:duration>${video.duration}</video:duration>`,
video.view_count &&
`<video:view_count>${video.view_count}</video:view_count>`,
video.tag && `<video:tag>${video.tag}</video:tag>`,
video.rating && `<video:rating>${video.rating}</video:rating>`,
video.expiration_date &&
`<video:expiration_date>${video.expiration_date}</video:expiration_date>`,
video.publication_date &&
`<video:publication_date>${video.publication_date}</video:publication_date>`,
video.family_friendly &&
`<video:family_friendly>${video.family_friendly}</video:family_friendly>`,
video.requires_subscription &&
`<video:requires_subscription>${video.requires_subscription}</video:requires_subscription>`,
video.live && `<video:live>${video.live}</video:live>`,
video.restriction &&
`<video:restriction relationship="${video.restriction.relationship}">${video.restriction.content}</video:restriction>`,
video.platform &&
`<video:platform relationship="${video.platform.relationship}">${video.platform.content}</video:platform>`,
video.uploader &&
`<video:uploader${video.uploader.info && ` info="${video.uploader.info}"`}>${video.uploader.content}</video:uploader>`,
`</video:video>\n`,
].filter(Boolean)
content += videoFields.join('\n')
}
}
if (item.lastModified) {
const serializedDate =
item.lastModified instanceof Date
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/lib/metadata/types/metadata-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
TemplateString,
Verification,
ThemeColorDescriptor,
Videos,
} from './metadata-types'
import type { Manifest as ManifestFile } from './manifest-types'
import type { OpenGraph, ResolvedOpenGraph } from './opengraph-types'
Expand Down Expand Up @@ -607,6 +608,7 @@ type SitemapFile = Array<{
languages?: Languages<string>
}
images?: string[]
videos?: Videos[]
}>

type ResolvingMetadata = Promise<ResolvedMetadata>
Expand Down
28 changes: 28 additions & 0 deletions packages/next/src/lib/metadata/types/metadata-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,31 @@ export type ThemeColorDescriptor = {
color: string
media?: string
}

export type Restriction = {
relationship: 'allow' | 'deny'
content: string
}

export type Videos = {
title: string
thumbnail_loc: string
description: string
content_loc?: string
player_loc?: string
duration?: number
expiration_date?: Date | string
rating?: number
view_count?: number
publication_date?: Date | string
family_friendly?: 'yes' | 'no'
restriction?: Restriction
platform?: Restriction
requires_subscription?: 'yes' | 'no'
uploader?: {
info?: string
content?: string
}
live?: 'yes' | 'no'
tag?: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com/about',
videos: [
{
title: 'example',
thumbnail_loc: 'https://example.com/image.jpg',
description: 'this is the description',
content_loc: 'http://streamserver.example.com/video123.mp4',
player_loc: 'https://www.example.com/videoplayer.php?video=123',
duration: 2,
view_count: 50,
tag: 'summer',
rating: 4,
expiration_date: '2025-09-16',
publication_date: '2024-09-16',
family_friendly: 'yes',
requires_subscription: 'no',
live: 'no',
restriction: {
relationship: 'allow',
content: 'IE GB US CA',
},
platform: {
relationship: 'allow',
content: 'web',
},
uploader: {
info: 'https://www.example.com/users/grillymcgrillerson',
content: 'GrillyMcGrillerson',
},
},
],
},
]
}
32 changes: 32 additions & 0 deletions test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ describe('app dir - metadata dynamic routes', () => {
)
})

it('should support videos in sitemap', async () => {
const xml = await (await next.fetch('/sitemap-video/sitemap.xml')).text()
expect(xml).toMatchInlineSnapshot(`
"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/about</loc>
<video:video>
<video:title>example</video:title>
<video:thumbnail_loc>https://example.com/image.jpg</video:thumbnail_loc>
<video:description>this is the description</video:description>
<video:content_loc>http://streamserver.example.com/video123.mp4</video:content_loc>
<video:player_loc>https://www.example.com/videoplayer.php?video=123</video:player_loc>
<video:duration>2</video:duration>
<video:view_count>50</video:view_count>
<video:tag>summer</video:tag>
<video:rating>4</video:rating>
<video:expiration_date>2025-09-16</video:expiration_date>
<video:publication_date>2024-09-16</video:publication_date>
<video:family_friendly>yes</video:family_friendly>
<video:requires_subscription>no</video:requires_subscription>
<video:live>no</video:live>
<video:restriction relationship="allow">IE GB US CA</video:restriction>
<video:platform relationship="allow">web</video:platform>
<video:uploader info="https://www.example.com/users/grillymcgrillerson">GrillyMcGrillerson</video:uploader>
</video:video>
</url>
</urlset>
"
`)
})

if (isNextStart) {
it('should optimize routes without multiple generation API as static routes', async () => {
const appPathsManifest = JSON.parse(
Expand Down
2 changes: 2 additions & 0 deletions test/turbopack-build-tests-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2100,6 +2100,8 @@
"app dir - metadata dynamic routes sitemap should not throw if client components are imported but not used in sitemap",
"app dir - metadata dynamic routes sitemap should optimize routes without multiple generation API as static routes",
"app dir - metadata dynamic routes sitemap should support alternate.languages in sitemap",
"app dir - metadata dynamic routes sitemap should support images in sitemap",
"app dir - metadata dynamic routes sitemap should support videos in sitemap",
"app dir - metadata dynamic routes sitemap should support generate multi sitemaps with generateSitemaps",
"app dir - metadata dynamic routes sitemap should support images in sitemap",
"app dir - metadata dynamic routes social image routes should fill params into dynamic routes url of metadata images",
Expand Down