Repository: vercel/next.js
Pull request: fix: pages router metadata bugs with React 19

When we updated `experimental.strictNextHead` to be true in #65418, we did not update all spots that would default to true in the case where the value was omitted entirely. 

This led to the default value not being correctly applied in pages router, which resulted in duplicate metadata w/ React 19.

Since we made the flag the default, we can also probably clean up this flag all together, but that can be done separately.

Fixes #81655
Fixes #81689

Changed files:
filename: packages/next/src/build/templates/pages.ts
patch: @@ -289,9 +289,8 @@ export async function handler(
                   reactLoadableManifest,
 
                   assetPrefix: nextConfig.assetPrefix,
-                  strictNextHead: Boolean(
-                    nextConfig.experimental.strictNextHead
-                  ),
+                  strictNextHead:
+                    nextConfig.experimental.strictNextHead ?? true,
                   previewProps: prerenderManifest.preview,
                   images: nextConfig.images as any,
                   nextConfigOutput: nextConfig.output,

filename: packages/next/src/server/config-shared.ts
patch: @@ -1466,6 +1466,7 @@ export const defaultConfig = Object.freeze({
     devtoolSegmentExplorer: false,
     browserDebugInfoInTerminal: false,
     optimizeRouterScrolling: false,
+    strictNextHead: true,
   },
   htmlLimitedBots: undefined,
   bundlePagesRouterDependencies: false,

filename: test/development/pages-dir/client-navigation/fixture/next.config.js
patch: @@ -4,9 +4,12 @@ module.exports = {
     // Make sure entries are not getting disposed.
     maxInactiveAge: 1000 * 60 * 60,
   },
-  experimental: {
-    strictNextHead: process.env.TEST_STRICT_NEXT_HEAD !== 'false',
-  },
+  experimental:
+    process.env.TEST_STRICT_NEXT_HEAD !== undefined
+      ? {
+          strictNextHead: process.env.TEST_STRICT_NEXT_HEAD === 'true',
+        }
+      : {},
   // scroll position can be finicky with the
   // indicators showing so hide by default
   devIndicators: false,

filename: test/development/pages-dir/client-navigation/fixture/pages/head-with-custom-metadata.js
patch: @@ -0,0 +1,14 @@
+import Head from 'next/head'
+
+export default function Page() {
+  return (
+    <div>
+      <Head>
+        <title>Title Page</title>
+        <meta property="og:title" content="Title Content" />
+        <meta name="description" content="Description Content" />
+      </Head>
+      <p>This is a page!</p>
+    </div>
+  )
+}

filename: test/development/pages-dir/client-navigation/rendering-head.test.ts
patch: @@ -8,14 +8,17 @@ import path from 'path'
 const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18
 
 describe('Client Navigation rendering <Head />', () => {
-  describe.each([[false], [true]])(
+  describe.each([[false], [true], [undefined]])(
     'with strictNextHead=%s',
     (strictNextHead) => {
       const { next } = nextTestSetup({
         files: path.join(__dirname, 'fixture'),
-        env: {
-          TEST_STRICT_NEXT_HEAD: String(strictNextHead),
-        },
+        env:
+          strictNextHead !== undefined
+            ? {
+                TEST_STRICT_NEXT_HEAD: String(strictNextHead),
+              }
+            : {},
       })
 
       function render(
@@ -37,7 +40,7 @@ describe('Client Navigation rendering <Head />', () => {
       test('header renders default charset', async () => {
         const html = await render('/default-head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta charSet="utf-8" data-next-head=""/>'
             : '<meta charSet="utf-8"/>'
         )
@@ -47,7 +50,7 @@ describe('Client Navigation rendering <Head />', () => {
       test('header renders default viewport', async () => {
         const html = await render('/default-head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta name="viewport" content="width=device-width" data-next-head=""/>'
             : '<meta name="viewport" content="width=device-width"/>'
         )
@@ -56,17 +59,17 @@ describe('Client Navigation rendering <Head />', () => {
       test('header helper renders header information', async () => {
         const html = await render('/head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta charSet="iso-8859-5" data-next-head=""/>'
             : '<meta charSet="iso-8859-5"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta content="my meta" data-next-head=""/>'
             : '<meta content="my meta"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta name="viewport" content="width=device-width,initial-scale=1" data-next-head=""/>'
             : '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
         )
@@ -76,17 +79,17 @@ describe('Client Navigation rendering <Head />', () => {
       test('header helper dedupes tags', async () => {
         const html = await render('/head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta charSet="iso-8859-5" data-next-head=""/>'
             : '<meta charSet="iso-8859-5"/>'
         )
         expect(html).not.toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta charSet="utf-8" data-next-head=""/>'
             : '<meta charSet="utf-8"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta name="viewport" content="width=device-width,initial-scale=1" data-next-head=""/>'
             : '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
         )
@@ -96,24 +99,25 @@ describe('Client Navigation rendering <Head />', () => {
           '<meta name="viewport" content="width=device-width"'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta content="my meta" data-next-head=""/>'
             : '<meta content="my meta"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<link rel="stylesheet" href="/dup-style.css" data-next-head=""/><link rel="stylesheet" href="/dup-style.css" data-next-head=""/>'
             : '<link rel="stylesheet" href="/dup-style.css"/><link rel="stylesheet" href="/dup-style.css"/>'
         )
-        const dedupeLink = strictNextHead
-          ? '<link rel="stylesheet" href="dedupe-style.css" data-next-head=""/>'
-          : '<link rel="stylesheet" href="dedupe-style.css"/>'
+        const dedupeLink =
+          strictNextHead !== false
+            ? '<link rel="stylesheet" href="dedupe-style.css" data-next-head=""/>'
+            : '<link rel="stylesheet" href="dedupe-style.css"/>'
         expect(html).toContain(dedupeLink)
         expect(
           html.substring(html.indexOf(dedupeLink) + dedupeLink.length)
         ).not.toContain('<link rel="stylesheet" href="dedupe-style.css"')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<link rel="alternate" hrefLang="en" href="/last/en" data-next-head=""/>'
             : '<link rel="alternate" hrefLang="en" href="/last/en"/>'
         )
@@ -129,12 +133,12 @@ describe('Client Navigation rendering <Head />', () => {
         // Expect exactly one `viewport`
         expect((html.match(/name="viewport"/g) || []).length).toBe(1)
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta charSet="iso-8859-1" data-next-head=""/>'
             : '<meta charSet="iso-8859-1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta name="viewport" content="width=500" data-next-head=""/>'
             : '<meta name="viewport" content="width=500"/>'
         )
@@ -144,98 +148,98 @@ describe('Client Navigation rendering <Head />', () => {
         const html = await render('/head')
         console.log(html)
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="article:tag" content="tag1" data-next-head=""/>'
             : '<meta property="article:tag" content="tag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="article:tag" content="tag2" data-next-head=""/>'
             : '<meta property="article:tag" content="tag2"/>'
         )
         expect(html).not.toContain('<meta property="dedupe:tag" content="tag3"')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="dedupe:tag" content="tag4" data-next-head=""/>'
             : '<meta property="dedupe:tag" content="tag4"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image" content="ogImageTag1" data-next-head=""/>'
             : '<meta property="og:image" content="ogImageTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image" content="ogImageTag2" data-next-head=""/>'
             : '<meta property="og:image" content="ogImageTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:alt" content="ogImageAltTag1" data-next-head=""/>'
             : '<meta property="og:image:alt" content="ogImageAltTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:alt" content="ogImageAltTag2" data-next-head=""/>'
             : '<meta property="og:image:alt" content="ogImageAltTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:width" content="ogImageWidthTag1" data-next-head=""/>'
             : '<meta property="og:image:width" content="ogImageWidthTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:width" content="ogImageWidthTag2" data-next-head=""/>'
             : '<meta property="og:image:width" content="ogImageWidthTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:height" content="ogImageHeightTag1" data-next-head=""/>'
             : '<meta property="og:image:height" content="ogImageHeightTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:height" content="ogImageHeightTag2" data-next-head=""/>'
             : '<meta property="og:image:height" content="ogImageHeightTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:type" content="ogImageTypeTag1" data-next-head=""/>'
             : '<meta property="og:image:type" content="ogImageTypeTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:type" content="ogImageTypeTag2" data-next-head=""/>'
             : '<meta property="og:image:type" content="ogImageTypeTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:secure_url" content="ogImageSecureUrlTag1" data-next-head=""/>'
             : '<meta property="og:image:secure_url" content="ogImageSecureUrlTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:secure_url" content="ogImageSecureUrlTag2" data-next-head=""/>'
             : '<meta property="og:image:secure_url" content="ogImageSecureUrlTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:url" content="ogImageUrlTag1" data-next-head=""/>'
             : '<meta property="og:image:url" content="ogImageUrlTag1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="og:image:url" content="ogImageUrlTag2" data-next-head=""/>'
             : '<meta property="og:image:url" content="ogImageUrlTag2"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="fb:pages" content="fbpages1" data-next-head=""/>'
             : '<meta property="fb:pages" content="fbpages1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta property="fb:pages" content="fbpages2" data-next-head=""/>'
             : '<meta property="fb:pages" content="fbpages2"/>'
         )
@@ -244,12 +248,12 @@ describe('Client Navigation rendering <Head />', () => {
       test('header helper avoids dedupe of meta tags with the same name if they use unique keys', async () => {
         const html = await render('/head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta name="citation_author" content="authorName1" data-next-head=""/>'
             : '<meta name="citation_author" content="authorName1"/>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta name="citation_author" content="authorName2" data-next-head=""/>'
             : '<meta name="citation_author" content="authorName2"/>'
         )
@@ -258,12 +262,12 @@ describe('Client Navigation rendering <Head />', () => {
       test('header helper renders Fragment children', async () => {
         const html = await render('/head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<title data-next-head="">Fragment title</title>'
             : '<title>Fragment title</title>'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<meta content="meta fragment" data-next-head=""/>'
             : '<meta content="meta fragment"/>'
         )
@@ -272,27 +276,28 @@ describe('Client Navigation rendering <Head />', () => {
       test('header helper renders boolean attributes correctly children', async () => {
         const html = await render('/head')
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<script src="/test-async-false.js" data-next-head="">'
             : '<script src="/test-async-false.js">'
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<script src="/test-async-true.js" async="" data-next-head="">'
             : ''
         )
         expect(html).toContain(
-          strictNextHead
+          strictNextHead !== false
             ? '<script src="/test-defer.js" defer="" data-next-head="">'
             : ''
         )
       })
 
       it('should place charset element at the top of <head>', async () => {
         const html = await render('/head-priority')
-        const nextHeadElement = strictNextHead
-          ? '<meta charSet="iso-8859-5" data-next-head=""/><meta name="viewport" content="width=device-width,initial-scale=1" data-next-head=""/><meta name="title" content="head title" data-next-head=""/>'
-          : '<meta charSet="iso-8859-5"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="title" content="head title"/><meta name="next-head-count" content="3"/>'
+        const nextHeadElement =
+          strictNextHead !== false
+            ? '<meta charSet="iso-8859-5" data-next-head=""/><meta name="viewport" content="width=device-width,initial-scale=1" data-next-head=""/><meta name="title" content="head title" data-next-head=""/>'
+            : '<meta charSet="iso-8859-5"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="title" content="head title"/><meta name="next-head-count" content="3"/>'
         const documentHeadElement =
           '<meta name="keywords" content="document head test"/>'
 
@@ -304,6 +309,35 @@ describe('Client Navigation rendering <Head />', () => {
             : `<head>${nextHeadElement}${documentHeadElement}`
         )
       })
+
+      test('custom meta properties are rendered only once', async () => {
+        const browser = await next.browser('/head-with-custom-metadata')
+
+        // Check that title appears only once
+        const titleElements = await browser.elementsByCss('title')
+        expect(titleElements).toHaveLength(1)
+        const titleText = await browser.elementByCss('title').text()
+        expect(titleText).toBe('Title Page')
+
+        // Check that each meta property appears only once
+        const ogTitleElements = await browser.elementsByCss(
+          'meta[property="og:title"]'
+        )
+        expect(ogTitleElements).toHaveLength(1)
+        const ogTitleContent = await browser
+          .elementByCss('meta[property="og:title"]')
+          .getAttribute('content')
+        expect(ogTitleContent).toBe('Title Content')
+
+        const descriptionElements = await browser.elementsByCss(
+          'meta[name="description"]'
+        )
+        expect(descriptionElements).toHaveLength(1)
+        const descriptionContent = await browser
+          .elementByCss('meta[name="description"]')
+          .getAttribute('content')
+        expect(descriptionContent).toBe('Description Content')
+      })
     }
   )
 })