next.js: Metadata: title template in layout.js doesn't work for page.js in the same level

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
  Platform: darwin
  Arch: x64
  Version: Darwin Kernel Version 22.3.0: Mon Jan 30 20:42:11 PST 2023; root:xnu-8792.81.3~2/RELEASE_X86_64
Binaries:
  Node: 18.14.0
  npm: 9.3.1
  Yarn: 1.22.19
  pnpm: 7.29.0
Relevant packages:
  next: 13.2.4-canary.4
  eslint-config-next: N/A
  react: 18.2.0
  react-dom: 18.2.0

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true), Metadata (metadata, generateMetadata, next/head, head.js)

Link to the code that reproduces this issue

https://github.com/joulev/debug/tree/nextjs-metadata-title-template-bug

To Reproduce

  • Clone the linked repo
  • next dev or next build
  • Check / and /about

Alternatively, check the deployment for / and /about.

Describe the Bug

/about title is “about | template” which is correct. But / title is “home” without the " | template" part, which is not correct.

Expected Behavior

/ title should be “home | template”.

Which browser are you using? (if relevant)

N/A

How are you deploying your application? (if relevant)

N/A

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 1
  • Comments: 42 (29 by maintainers)

Most upvoted comments

Thanks @gnoff for your time in answering. The description you’ve given is really clear.

In the first part, you are right around the title rendering - it probably should have had ‘’ defined for it to render a title.

To be honest, the above was probably overkill - and I do understand that the title and template gets cascaded down until overwritten. The main point I was trying to make is that I believe it would make more sense for the template to define from the current level downwards, and that all the use cases would still be achievable- but with a better DX for the reasons I mentioned above (colocation of title/template, alignment of the template with the actual layout, etc).

I did have to reread the part about templateForChildTitles a few times to be honest, because I was still confused about the fact that the template didn’t apply to the child /products/explore/page.js. but the default title did.

I understand your logic, and it’s probably just a matter of a difference of opinion on what the best developer experience would be at this stage.

Thanks for the hard work.

@mjth

// /products/[productname]/page.js
export const metadata = {
 //This can be empty. Uses the closest template instead. -> 'Product Name - A Foo Product'
//Since title has not been defined no %s is rendered.
};

I don’t quite follow this part. this metadata doesn’t declare a title, why does it use a template? Also wouldn’t the computed title have an extra space if you did treat the title to be templatized as an empty string?

capatilise(params.productname) + ' %s- A Foo Product' -> "Product Name  - A Foo Product"

In trying to understand your examples though it made me realize that you might be thinking about how we “find” metadata for a page in a reverse order of sorts. My impression is you think about it as starting from the Page and looking up the Layout tree to find matching metadata whereas it actually starts with the root layout and computes a final value as it goes down. I’ll try to clarify my mental model for the titles and see if this helps.

I think our Docs need to better clarify our intent here (or we need to make these properties longer) but here is how I think about the options

In a Layout

title defines default titles to use as well as templates to be applied to deeper title definitions

title (as string) -> apply nearest templateForChildTitles if any, this is the default title
title.absolute -> ignore any templates, this is the default title
title.default -> same as title (as string)
title.template -> set a new templateForChildTitles

these titles are always fallbacks for when you do not configure a title for your page. As we traverse each layout we keep track of the default title, augmenting it with templates as needed. We also keep track of whether a new template is to be used for deeper title definitions

In a Page

title defines the page title. If you don’t provide a title the nearest default title is used

(no title) -> use the nearest default title
title (as string) -> apply nearest templateForChildTitles if any, this is the title used
title.absolute -> ignore any templates, this is the title used

It’s important to note that a template never does anything on it’s own. It always requires a title to augment (either a default title defined for a Layout or a title defined for a page)

The type structure could make this distinction more explicit

// Layouts
{
  defaultTitle?: string | { absolute: string };
  titleTemplateForChildTitles?: string
}

// Pages
{
  title?: string | { absolute: string };
}

But generally we have a philosophy that it metadata is changing together it should be grouped together. This is why the template is embedded in the title definition and not a sibling property. We also try to communicate enough intent without using long property names.

Another thing that might help the mental model is to recognize that we don’t start with the Page and bubble up to find the first matching title/template. We start at the root Layout and compute the title as we go deeper. If we never hit a Page that defines title then we just use the currently computed title. This is why we haven’t really “skipped” the template. The template is just there to augment deeper definitions if one exists. sometimes one will not exist

I think we need to do some Docs updates to make this mental model show through clearer, but I do think our position is consistent and does allow for the greatest range of expression of titles.

If you want the behavior where the default title does not inherit the locally defined template you can do

{
  title: {
    absolute: "My Bespoke Default",
    template: "%s - The Template",
  }
}

And if you want the template to apply to the default you can easily do that

{
  title: {
    absolute: "Default With a Template - The Template",
    template: "%s - The Template",
  }
}

The most awkward case I can see is when you want your Page to use the template defined in your Layout but the same absolute trick works there too, it’s just in a separate file.

@huozhi, I’m not sure if I understand correctly, but I don’t think giving another title in the root page is a good sollution. If you want to have a behavior like this:

Route url Rendered title
/ Home | Brand
/about About | Brand

Then you’d have to manually write ‘Home | Brand’ in the metadata title in app/page.tsx like so:

// layout.tsx
export const metadata = {
  title: {
    default: 'Brand',
    template: '%s | Brand',
  },
};

// page.tsx
export const metadata = {
  title: "Home | Brand";
};

// about/page.tsx
export const metadata = {
  title: "About";
}

Which ultimately kind of defeats the purpose of title templates.

I don’t see any way you could use the current behavior to your advantage and even if there is one I think it’s safe to assume that there would be significantly more people wanting to use the title template in the root page (if they wanted to have just the default title on the root page, they could achieve it by just not specifying a title in the app/page.tsx metadata)

Also, as @mjth said, this is really bad DX-wise. Even if we introduce a change in the docs, it would still be really inconstant with the layout working on pages on the same level but the metadata not.

In my opinion the current behavior is really unintuitive and not really beneficial for anyone so I don’t think a change in the documentation is the right way to approach this issue. And even if there would still be any need to use the current behavior, we could make it opt-in with a boolean, as @JesseKoldewijn suggested. It could look something like this:

// layout.tsx
export const metadata = {
  title: {
    default: 'Brand',
    template: '%s | Brand',
  },
};

// page.tsx
export const metadata = {
  title: {
    content: "The home of brand",
    ignoreTemplates: true
  }
} // The final title would "The home of brand", without the " | Brand" suffix

That way you could also opt-out of templates in lower level files and the whole thing would be much more intuitive. (And as I mentioned before, you could still achieve the default “Brand” behavior by not specifying a title in a page’s metadata)

I agree with @Confuze… and hope this issue is opened again @huozhi

I was attempting to specify a title template in Root Layout, but it doesn’t apply to the Root Page (and there is no way to do this). If this was designed in case you needed an escape hatch, you could either just not define the template on root, or override it (like you would for layouts further down the chain anyway…)

IMO it’s also very confusing from a DX perspective… The layout is designed to wrap the page on the same level… but the metadata only wraps children pages?

Ran into this issue today, really weird. I added a comment to the documentation informing about this behavior. I have no clue why this would be desired.

Yeah I’m just as “Confuzed” about this matter myself as well

@Confuze @JesseKoldewijn

We try to keep our boolean config as limited as possible which is why the current set of configurable options is descriptive (absolute is a description of the absolute title rather than a flag like skipTemplate: true)

It may seem a little odd (b/c the title maybe seems like a minor reason to do this) but you can hook into the template behavior you want by structuring the directory slightly differently. route groups are the way to do this

// /layout.js
export const metadata = {
  title: {
    default: "Brand",
    template: "%s | Brand",
  }
}

// if you use /page.js
export const metadata = {
  title: "Home | Brand" // title will be "Home | Brand" because the template is not used (same depth)
}

// try instead /(index)/page.js
export const metadata = {
  title: "Home" // title will be "Home | Brand" because the template is used (page is deeper than layout)
}

now your Page is in a “deeper” segment than the Layout. It’s a little nuanced because earlier I said route-segment and really its based on folder hierarchy but usually these are the same thing. But in this case the page is part of a route-group which has no url representation but when the metadata resolves it is treated like it is a “child” of the Layout

that said, I personally wouldn’t do this, I’d just use a shared helper to make the template uniform when I wanted this behavior

@Confuze yeah there is a tradeoff. If you want the Page for a rouge segment to use the template that is defined for that same route segment then you’d either need to repeat it or extract to some external file

// /title-helper.js
export const template = '%s - Brand';

// /layout.js
import { template } = 
export const metadata = {
    title: {
        default: "Brand",
        template,
    }
}

// /page.js
export const metadata {
    title: template.replace("%s", "Home"),
}

@JesseKoldewijn I’m not sure if I am misunderstanding the issue but I think what you’d want in that case is

export const metadata = {
     title: {
       absolute: "The home of brand",
     }
}

using absolute opts the title out of using any templates

Hey man, sorry for the confusion. I’m kinda doozy after a long day plus some stuff in my personal life.

What I meant was something like discribed by confuze but sort of the other way around. Meaning the ability to override the current behaviour allowing the following title structure while still using the defined templates without having to duplicate or set the full title with template included as the defaupt.

Example:

// Override: true
Home | brand 
About | brand

// Override: false
brand
About | brand

Ps. Sorry for the poor formatted example, I’m on my phone in the hospital for my grandpa.

btw, this is exactly what I meant. @JesseKoldewijn posted this comment while I was writing mine lol.

So would in this case maybe a override boolean be possible that defaults to the current behaviour? This way you could have it both ways. This boolean as confuze layed out in a snippet could be included into the meta object as an optional override.

@Confuze 's snippet

export const metadata = {
     title: {
       content: "The home of brand",
       ignoreTemplates: true
     }
}

bro I became your name again, looking all over the place like “am I blind?” 🤣

@JesseKoldewijn I think a config option would be an overkill in this case.


@huozhi I think in that case shouldn’t it be like this? (It’s literally the example in the documentation, actually.)

// layout.tsx
export const metadata = {
  title: {
    default: 'Brand',
    template: '%s | Brand',
  },
};

// page.tsx
export const metadata = {
  // just don't specify a title
};

// about/page.tsx
export const metadata = {
  title: "About";
}

Or we can also use [ ] for example to specify optional parts inside the template, so we can have %s[ | ]Brand for example?

Since layout.tsx also covers the same-level page.tsx, I found this design very confusing.

I btw mainly meant the config option as in an option in the metadata object btw. Just to clarify🤙

@joulev If you don’t specify title for root page then it will use the default title from root layout, it will render "Brand" if you don’t specify any.

Ok this is indeed the desired behaviour (#45965), but I think I’ll keep this issue open as I still don’t understand the rationale behind this.

Also I think it’s a good idea to update the documentation to reflect this, especially as the Title templates section uses an example where app/page.js and a same-level app/layout.js are used, which makes it quite misleading.

After digging a bit I found this

https://github.com/vercel/next.js/blob/d59aa9655e7a46056a4e98c3a79d40a118f6157d/packages/next/src/lib/metadata/resolve-metadata.ts#L317-L325

So it does look like this is the desired behaviour… but may I ask why?

Simply changing it to

titleTemplates = { 
  title: resolvedMetadata.title?.template || null, 
  openGraph: resolvedMetadata.openGraph?.title?.template || null, 
  twitter: resolvedMetadata.twitter?.title?.template || null, 
}

fixes the issue.