Next.js App Router

Metadata, SEO, Images and Fonts

The Metadata API, sitemap and robots, Open Graph images, next/image and next/font, and Core Web Vitals habits.

Lesson 0714 min read#nextjs#seo#metadata#images#performance

On this page

  1. Words used in this lesson
  2. Metadata: what the head should say
  3. Files that make metadata for you
  4. next/image: size, format and shift
  5. next/font: no layout shift, no request to Google
  6. Core Web Vitals habits for a Next.js page
  7. Common mistakes
  8. Try it
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: Search engines and chat apps do not run your page like a person does. They read the <head>: the title, the description, the canonical link, the share image. In Next.js you describe those with a metadata object or a generateMetadata function, and special files (sitemap.ts, robots.ts, opengraph-image.tsx) make the rest. next/image and next/font then protect the speed numbers that Google measures. I built each piece, read the real HTML it produced, and measured what it did.

When you share a link in a chat, a small card appears with a title, a line of text and a picture. When Google lists your page, it shows a title and a description. All of that comes from tags in the <head> of your HTML. If the tags are wrong or missing, the card is ugly or empty, and the search result is worse. This lesson shows how to make them right in the App Router, and how to keep images and fonts from slowing the page down.

This is lesson 7 of ten. Lesson 6 showed that a page can stream in pieces. Here you will see one place where that matters for SEO: when the <title> arrives. The next lesson, Environment, Configuration, Deploying and Production Habits, covers the settings that make a site live, including the NEXT_PUBLIC_ variables that this blog uses for its own site URL. For the meaning of LCP, CLS and INP, see Core Web Vitals for Engineers. I do not repeat that here.

What I ran / checked. Linux, Node.js v22.22.0, Next.js 16.3.5 (this repo's version), React 19.3.0, Turbopack, and sharp (the image library Next.js uses). I built a throwaway app in /tmp with a layout, a static page, a blog route with generateMetadata, a generated Open Graph image, sitemap.ts, robots.ts, next/image and next/font. I ran next build and next start, read the real HTML with curl, fetched the real images, and used Playwright with Chromium to list the network requests and to measure layout shift. Sources I read (in my own words): inside node_modules/next/dist/docs/01-app/, the guides "Metadata and OG images", "Images", "Fonts", "Production checklist", and the API pages generate-metadata, image, font, image-response, sitemap, robots, opengraph-image and loading. I also read this blog's own app/layout.tsx, app/sitemap.ts, app/robots.ts and app/og/[[...slug]]/route.tsx (read-only) as real examples. Not verified: I did not submit anything to Google or test a real social card on a real network. The picture of my Open Graph image comes from fetching it and looking at it. next/font/google downloaded the Inter font during my build, so the sandbox could reach Google Fonts, and yours may need that too. The image sizes and the layout-shift number are from one small test and will vary.

Words used in this lesson

  • Metadata is data about a page that is not shown in the page body: title, description, icons, share image. It lives in the <head>.
  • SEO (search engine optimization) means helping search engines understand your page, so the right people find it.
  • Metadata API is the Next.js way to write metadata with code, instead of writing <head> tags by hand.
  • generateMetadata is a function you export from a page or layout. It returns metadata that can depend on params or on data.
  • Open Graph (OG) is a set of <meta property="og:..."> tags that chat apps and social sites read to make a link card.
  • Canonical URL is the one official address of a page, given in <link rel="canonical">. It tells search engines which URL to count when several URLs show the same page.
  • metadataBase is the site's base URL. Next.js uses it to turn relative URLs in metadata into full ones.
  • Sitemap is a file (sitemap.xml) that lists the URLs you want search engines to find.
  • robots.txt is a file that tells crawlers which paths they may visit.
  • JSON-LD is structured data (a small JSON block in a <script> tag) that describes the page for search engines, such as "this is a blog post by this author".
  • HTML-limited bot is a crawler that reads the HTML but may not run JavaScript, such as Twitterbot. Next.js detects these by user agent.
  • Image optimization means serving an image in the right size and in a smaller format (WebP, AVIF) for the visitor's browser.
  • Layout shift (CLS) means content that jumps while the page loads, usually because an image or font arrived late.
  • Font swap means showing text in a fallback font first, then switching to the real font when it loads.

Metadata: what the head should say

A page needs a few tags to be a good citizen on the web:

TagWhat reads itWhat it does
<title>Browser tab, search resultsThe page name
<meta name="description">Search resultsThe grey text under the title
<link rel="canonical">Search enginesThe official URL of this page
<meta name="robots">Search enginesnoindex, nofollow and so on
og:title, og:description, og:imageChat apps, social sitesThe link card
twitter:card, twitter:imageX (Twitter)Card style
<meta name="viewport">Mobile browsersPage width

You never write these by hand in the App Router. You export metadata from a Server Component file (page.tsx or layout.tsx). The docs say that metadata and generateMetadata only work in Server Components. Two tags are always added for you, even if you do nothing: charset and viewport.

Static metadata

If the metadata does not depend on data, export an object:

JavaScript
// app/about/page.tsximport type { Metadata } from 'next'export const metadata: Metadata = {  title: 'About us',  description: 'Who we are.',  alternates: { canonical: '/about' },}export default function About() { return <h1>About</h1> }

And in the root layout I set the site-wide defaults, with a title template:

JavaScript
// app/layout.tsxexport const metadata: Metadata = {  metadataBase: new URL('https://example.com'),  title: { default: 'Lab Site', template: '%s | Lab Site' },  description: 'A lab site for the metadata lesson.',  openGraph: { type: 'website', siteName: 'Lab Site' },}export const viewport: Viewport = { themeColor: '#0b1120', colorScheme: 'dark' }

Notice that viewport is a separate export. In current Next.js, themeColor and colorScheme do not go inside metadata. They go in viewport.

Here is the real <head> that curl got for /about. I kept only the metadata tags:

HTML
<meta name="viewport" content="width=device-width, initial-scale=1"/><meta name="theme-color" content="#0b1120"/><meta name="color-scheme" content="dark"/><title>About us | Lab Site</title><meta name="description" content="Who we are."/><link rel="canonical" href="https://example.com/about"/><meta property="og:title" content="About us | Lab Site"/><meta property="og:description" content="Who we are."/><meta property="og:site_name" content="Lab Site"/><meta property="og:type" content="website"/><meta name="twitter:card" content="summary"/><meta name="twitter:title" content="About us | Lab Site"/><meta name="twitter:description" content="Who we are."/>

Look at what happened:

  • The title became About us | Lab Site. The template %s | Lab Site took the page's title.
  • The canonical was written as /about and came out as https://example.com/about. That is metadataBase at work: relative URLs in metadata are joined to it.
  • I never wrote an og:title or a twitter:* tag. Next.js copied them from the title and description.
  • og:site_name came from the layout, because this page did not set openGraph itself.

This blog does the same. Its app/layout.tsx sets metadataBase, a title with a template (%s · blogs), openGraph and twitter defaults, and a viewport export. Each page then sets its own canonical, and the layout comment says why: a site-wide canonical would be inherited by every page, which would be wrong.

What happens without metadataBase

I removed metadataBase and built again. The build printed:

TEXT
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase

So the build does not fail. It guesses http://localhost:3000. If you ship that, your share images point to localhost, and no chat app can load them. This warning is easy to miss. In a real project the base URL comes from an environment variable (this blog reads NEXT_PUBLIC_SITE_URL and a few Vercel variables, which is lesson 8).

Dynamic metadata with generateMetadata

For a blog post, the title depends on the post. Export a function:

JavaScript
// app/blog/[slug]/page.tsxexport async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise<Metadata> {  const { slug } = await props.params          // params is a Promise in this version  const post = await getPost(slug)  if (!post) return {}  return {    title: post.title,    description: post.summary,    alternates: { canonical: `/blog/${post.slug}` },    robots: post.draft ? { index: false, follow: false } : undefined,    openGraph: { type: 'article', title: post.title, description: post.summary, publishedTime: post.date },  }}

Two details. params is a Promise, as everywhere in this version, so you await it. And I set robots: { index: false, follow: false } for drafts. The real output for the draft post:

TEXT
$ curl -s localhost:3108/blog/secret-draft | grep -o '<meta name="robots"[^>]*>'<meta name="robots" content="noindex, nofollow"/>

And for the normal post /blog/hello-next:

HTML
<title>Hello Next.js | Lab Site</title><meta name="description" content="A first post about the App Router."/><link rel="canonical" href="https://example.com/blog/hello-next"/><meta property="og:title" content="Hello Next.js"/><meta property="og:description" content="A first post about the App Router."/><meta property="og:image" content="https://example.com/blog/hello-next/opengraph-image?694292566d247c04"/><meta property="og:image:type" content="image/png"/><meta property="og:image:width" content="1200"/><meta property="og:image:height" content="630"/><meta property="og:image:alt" content="Blog post cover"/><meta property="og:type" content="article"/><meta property="article:published_time" content="2026-05-01"/><meta name="twitter:card" content="summary_large_image"/><meta name="twitter:image" content="https://example.com/blog/hello-next/opengraph-image?694292566d247c04"/>

The og:image tags came from a file I did not mention in the metadata at all. We will get to it in a moment.

The merge trap

Now look again at og:site_name. On /about it was there. On the blog post it is gone. The layout set openGraph: { type: 'website', siteName: 'Lab Site' }. The post set its own openGraph: { type: 'article', title, ... }. I expected the two to be combined. They were not.

The docs explain: metadata from several segments is merged shallowly. If a later segment defines openGraph, it replaces the whole openGraph object from the layout. It does not merge the fields inside. The same is true for robots and other nested fields.

Setting openGraph in a page throws away the layout's openGraph. If you need to keep siteName and other shared fields, pull them into a shared variable (the docs suggest this) and spread it into each page's openGraph. Then check the real HTML. I found my missing og:site_name only because I read the output.

Do not fetch twice: cache()

generateMetadata and the page often need the same post. If both call your database, you pay twice. The fix is React's cache(), which shares one result inside one request:

JavaScript
// lib/posts.tsimport { cache } from 'react'export const getPost = cache(async (slug: string) => {  console.log('[db] getPost', slug)  return POSTS.find((p) => p.slug === slug) ?? null})

I counted the log lines during next build, with and without cache(), for my three posts (each post's metadata and page both call getPost):

TEXT
without cache(): 6 lines   (two per post)with cache():    3 lines   (one per post)

(For plain fetch calls with the same URL, Next.js already shares the result. That is lesson 2. cache() is for a database or any other function.)

Streaming metadata: when does the title arrive?

Here is the SEO side of lesson 6. A static page has its metadata at build time, so the title is in the first bytes. But a dynamic page (one that reads cookies, for example) must run generateMetadata at request time. The docs say Next.js then streams the metadata separately, so it does not hold up the visible page. But bots such as Twitterbot expect the title in the <head>. So the docs say Next.js detects them by their user agent and waits.

I tested it. My /dynamic page reads cookies() and its generateMetadata sleeps 800 ms. I asked for it as a normal Chrome and as Twitterbot/1.0, and checked where the <title> appeared in the HTML:

TEXT
== UA: Chrometitle in <head>: Falsetitle at 7090  head ends 898  body starts 905   <title>Dynamic guest | Lab Site</title>== UA: Twitterbot/1.0title in <head>: Truetitle at 825   head ends 1432 body starts 1439  <title>Dynamic guest | Lab Site</title>

And the timing of the first byte:

TEXT
Chrome/120      first byte after 32 ms    done after 823 msTwitterbot/1.0  first byte after 820 ms   done after 820 ms

For a browser, the page started in 32 ms, and the title came later in the stream (the title was at byte 7090, after the </head> at byte 898, inside the body). For Twitterbot, the server waited 800 ms and put the title in the real <head>. That is the design: a human gets a fast page, and a bot that cannot run scripts gets standard HTML. You can change which user agents count as bots with htmlLimitedBots in next.config.ts.

Two takeaways: slow metadata makes bots wait, so keep generateMetadata fast (use cache(), and do not fetch what you do not need). And static pages avoid the whole question, because their metadata is ready at build time.

Files that make metadata for you

Some metadata is not an object but a file with a special name. Put it in a route folder and Next.js adds the tags and the URL. The docs list favicon, icon, apple-icon, manifest, robots, sitemap, opengraph-image and twitter-image. I built the three that matter most.

opengraph-image.tsx: a share image made with code

Put an opengraph-image.tsx next to a page and export a function that returns an ImageResponse. You write JSX, and Next.js turns it into a PNG. Mine, for the blog post route:

JavaScript
// app/blog/[slug]/opengraph-image.tsximport { ImageResponse } from 'next/og'import { getPost } from '@/lib/posts'export const alt = 'Blog post cover'export const size = { width: 1200, height: 630 }export const contentType = 'image/png'export default async function Image({ params }: { params: Promise<{ slug: string }> }) {  const { slug } = await params  const post = await getPost(slug)  return new ImageResponse(    (      <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column',                    justifyContent: 'center', padding: 80, background: '#0b1120', color: 'white' }}>        <div style={{ fontSize: 28, color: '#a78bfa' }}>Lab Site</div>        <div style={{ fontSize: 72, fontWeight: 700, marginTop: 20 }}>{post?.title ?? 'Not found'}</div>      </div>    ),    { ...size },  )}

The exports alt, size and contentType become the og:image:alt, og:image:width, og:image:height and og:image:type tags you saw. The ?694292566d247c04 at the end of the image URL is a hash. It changes when the image changes, so chat apps do not keep an old picture.

I fetched the image:

TEXT
HTTP/1.1 200 OKcache-control: public, max-age=0, must-revalidatecontent-type: image/png/tmp/og.png: PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlaced     (25,659 bytes)

A real 1200 by 630 PNG, 25 KB. I opened it and saw the dark background, the purple "Lab Site" and the title "Hello Next.js" in large letters. The docs list limits you should know: ImageResponse uses Satori, so only flexbox and a subset of CSS work (no display: grid), the bundle is limited to 500 KB including fonts and images, and only ttf, otf and woff fonts are accepted.

This blog does the same with a route handler instead of a file convention: app/og/[[...slug]]/route.tsx returns an ImageResponse for any page in its registry, and the layout points openGraph.images at /og. The code comment explains a security detail worth copying: only pages that exist get an image, "so nobody can make this endpoint print arbitrary text on your domain".

sitemap.ts

A sitemap lists the URLs you want crawled. Mine:

JavaScript
// app/sitemap.tsimport type { MetadataRoute } from 'next'export default function sitemap(): MetadataRoute.Sitemap {  return [    { url: 'https://example.com/', lastModified: '2026-06-01' },    { url: 'https://example.com/about', lastModified: '2026-06-01' },    ...allPosts().filter((p) => !p.draft).map((p) => ({ url: `https://example.com/blog/${p.slug}`, lastModified: p.date })),  ]}

And what curl localhost:3108/sitemap.xml returned (shortened):

XML
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc><lastmod>2026-06-01</lastmod></url><url><loc>https://example.com/about</loc><lastmod>2026-06-01</lastmod></url><url><loc>https://example.com/blog/hello-next</loc><lastmod>2026-05-01</lastmod></url><url><loc>https://example.com/blog/caching-101</loc><lastmod>2026-06-10</lastmod></url></urlset>

The draft post is not in it, because I filtered it out. That matches its noindex: a draft should be in neither place. In the build table, /sitemap.xml shows ○: it is static, built once. The docs say sitemap.js is cached by default unless it reads request data. If you have many URLs (the sitemap limit is large but not endless), generateSitemaps splits them into several files; I only read about it and did not run it.

This blog's own sitemap.ts maps every page to a URL with a real lastModified taken from git history. Its comment says Google ignores changefreq and priority, so they are left out, and that an honest lastmod is what helps. I find that a good habit: fill only the fields that carry real information.

robots.ts

JavaScript
// app/robots.tsimport type { MetadataRoute } from 'next'export default function robots(): MetadataRoute.Robots {  return {    rules: [{ userAgent: '*', allow: '/', disallow: ['/private/'] }],    sitemap: 'https://example.com/sitemap.xml',  }}

The real response:

TEXT
User-Agent: *Allow: /Disallow: /private/Sitemap: https://example.com/sitemap.xml

with content-type: text/plain. This blog's robots.ts has a clever safety rule: if the deployment is not meant to be indexed (a preview or branch deployment), it returns disallow: "/" for everyone. That stops preview sites from competing with the real site.

robots.txt is not security, and Disallow is not noindex. robots.txt asks well-behaved crawlers not to visit a path. The path can still appear in search results if other sites link to it, and anyone can still open it. To keep a page out of search results, use robots: { index: false } (the meta tag) or an X-Robots-Tag header, and let the crawler visit the page to see it. To protect data, use auth (lesson 5).

JSON-LD

For structured data, you put a <script type="application/ld+json"> in the page. The docs have a guide on it. Mine, from the blog post page (the replace of < is a safety step so the JSON cannot close the script tag early):

JavaScript
const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, datePublished: post.date }<script type="application/ld+json"  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />

The HTML:

HTML
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"Hello Next.js","datePublished":"2026-05-01"}</script>

next/image: size, format and shift

Images are usually the largest files on a page, and the most common cause of the LCP and CLS problems. next/image fixes three things at once: it serves the right size, a smaller format, and it reserves the space so the page does not jump.

My test page:

JavaScript
import Image from 'next/image'import hero from './hero.png'     // static import: Next.js reads width and height from the fileexport default function Home() {  return (    <main>      <Image src={hero} alt="Purple and blue abstract bubbles" preload sizes="(min-width: 800px) 800px, 100vw" />      <Image src="https://images.example.com/team/photo.jpg" alt="The team" width={600} height={400} />      <img src="/plain.png" alt="plain img tag" />    </main>  )}

The config allows one remote host and turns on AVIF and WebP:

JavaScript
images: {  formats: ['image/avif', 'image/webp'],  remotePatterns: [new URL('https://images.example.com/**')],},

The HTML for the first image (I shortened the srcSet):

HTML
<img alt="Purple and blue abstract bubbles" width="1600" height="900" decoding="async"     sizes="(min-width: 800px) 800px, 100vw"     srcSet="/_next/image?url=...hero.png&w=640&q=75 640w, ...&w=750&q=75 750w, ... &w=3840&q=75 3840w"     src="/_next/image?url=...hero.png&w=3840&q=75"/>

Things to see: width="1600" height="900" came from the file (static import), so the browser can reserve the right box before the image arrives. The srcSet lists many widths, so the browser picks one that fits. And the URL goes through /_next/image, which is Next.js's own optimizer. The remote image got loading="lazy". The first one (with preload) did not, and the head has a <link rel="preload" as="image" ...> for it.

In Next.js 16 the priority prop is deprecated. The docs say to use preload instead (which adds the <link> in the head). They also say that in most cases you should use loading="eager" or fetchPriority="high" for an above-the-fold image, and preload only for the one image that is the LCP element. Do not preload every image.

What the optimizer sent

I requested the same image from /_next/image with different Accept headers (the header a browser uses to say which formats it understands). The original PNG is 156,375 bytes.

TEXT
w=640   Accept=image/avif,image/webp,image/*   Content-Type: image/avif   4,543 bytesw=640   Accept=image/webp,image/*              Content-Type: image/webp   4,500 bytesw=640   Accept=image/png                       Content-Type: image/png   32,203 bytesw=1080  Accept=image/avif,image/webp,image/*   Content-Type: image/avif   7,249 bytesw=1080  Accept=image/webp,image/*              Content-Type: image/webp   8,398 bytesw=1080  Accept=image/png                       Content-Type: image/png   83,978 bytes

A phone that wants a 640 px image and understands AVIF gets 4.5 KB instead of 156 KB. That is a 97% saving, for a simple picture of gradients and circles (a photo will not shrink this much; I used a synthetic image, so treat the percentage as a best case). The response also carried Vary: Accept (the cache must keep one copy per format) and Cache-Control: public, max-age=315360000, immutable, because the file name of a static import has a hash.

The optimizer is a locked door

An open image optimizer would be a free tool for anyone to use your server. So Next.js 16 asks you to list what is allowed. I tried three bad requests:

TEXT
w=999 (not in the allowed list):        400q=50  (quality not in the allowed list): 400url on a host that is not allowed:       400    body: "url" parameter is not allowed

All refused. The docs confirm: widths come from a fixed list, qualities must be listed (the default is only [75] since version 16), and remote images need remotePatterns. The older domains option is deprecated for being too loose.

Missing size: loud in dev, quiet in production

If you use next/image with a remote URL and forget width and height, what happens? I made two bad pages. In next dev the response was 500 with a clear error:

TEXT
⨯ Error: Image with src "https://images.example.com/a.jpg" is missing required "width" property.⨯ Error: Invalid src prop (https://not-allowed.example.org/a.jpg) on `next/image`, hostname "not-allowed.example.org" is not configured under images in your `next.config.js`

But in the production build, both pages built fine and returned 200. The first rendered an <img> with no width and height. The second rendered an <img> whose URL the optimizer then refused with 400. So the dev server protects you, and production does not. Run your pages in dev (and look at the console) before you ship, and test with build and start too.

Does reserving space really help? A measured example

I made two pages with the same slow image (a route handler that waits 700 ms before sending it). One page has a plain <img> with no size. The other has width={1600} height={900}. I measured layout shift with Chromium's layout-shift events:

TEXT
/cls-bad   CLS = 0.0302 | text-under-image top at 655 px/cls-good  CLS = 0.0000 | text-under-image top at 655 px

Both pages end with the text at the same place (655 px from the top). But the one without a size pushed the text down when the image arrived, and Chromium recorded a shift of 0.0302. With the size set, the shift was exactly 0. A shift of 0.03 is small, because my page is tiny and only one line of text moved. On a real page with more content below the image, the number grows quickly, and the "good" limit for CLS is 0.1. The cure costs nothing: always give an image its size. next/image does it for you.

next/font: no layout shift, no request to Google

Fonts cause two problems: a late font makes text jump (swap), and loading from another site adds a connection. next/font fixes both by downloading the font at build time and serving it from your own site. My layout:

JavaScript
import localFont from 'next/font/local'import { Inter } from 'next/font/google'const mine  = localFont({ src: './MyFont.otf', variable: '--font-mine', display: 'swap' })const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' })// ...<html lang="en" className={`${mine.variable} ${inter.variable}`}>  <body style={{ fontFamily: 'var(--font-inter)' }}>{children}</body></html>

I loaded /about in Chromium and listed every host the browser contacted, and every font file it fetched:

TEXT
hosts contacted: [ 'localhost:3108' ]font files requested: [  '/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2',  '/_next/static/media/MyFont-s.p.274rpku761o_e.otf']computed font-family on body: Inter, "Inter Fallback"

One host only: my own. Even though I asked for a Google font, no request went to Google when the page loaded. The font file was downloaded during next build and is now a hashed static file. There is no third-party request, no extra DNS and TLS handshake, and no privacy leak to a font provider.

The head contained <link rel="preload" ... as="font" type="font/woff2"> for the font, so it starts loading early. And in the generated CSS I found this:

CSS
@font-face{font-family:mine;src:url(../media/MyFont-s.p.274rpku761o_e.otf)format("opentype");font-display:swap}@font-face{font-family:mine Fallback;src:local(Arial);ascent-override:89.79%;descent-override:22.36%;line-gap-override:0.0%;size-adjust:107.89%}

The second rule is the clever one. Next.js made a fallback font (Arial) and adjusted its size and spacing (size-adjust, ascent-override) to match the real font. So while the real font loads, the fallback takes almost the same space. When the swap happens, the text barely moves. That is how next/font avoids layout shift without you doing anything.

Notes from the docs:

  • You can use a variable font, which is one file for all weights, and then you do not need to list weights. Google fonts that are not variable need a weight.
  • subsets limits the download. Ask only for the scripts you use (latin).
  • Use variable: '--font-x' and CSS variables if you use Tailwind or want to switch fonts in CSS.
  • display: 'swap' shows the fallback first, and the swap is made gentle by the size adjustment above.

Core Web Vitals habits for a Next.js page

Here is what this lesson, plus lessons 1 and 6, gives you as a checklist. The metrics themselves are in Core Web Vitals for Engineers.

  • LCP (the biggest thing painted): the LCP element is often an image. Give it preload or fetchPriority="high", serve it through next/image, and send HTML fast (static pages, small shell, streaming).
  • CLS (jumping content): set image sizes, use next/font, and make Suspense fallbacks about the same height as the real content.
  • INP (slow response to clicks): keep client JavaScript small. Server Components help; lesson 1 has the numbers.
  • Measure real users. The docs have a useReportWebVitals hook to send the numbers to your analytics. A tool like Lighthouse is a lab test and should be paired with real data, as the production checklist says.

The docs' production checklist also reminds you of the metadata items: use the Metadata API, create OG images, and generate sitemaps and robots files.

Common mistakes

  • No metadataBase. Share images point to localhost and the build only prints a warning.
  • Setting openGraph in a page and losing the layout's openGraph (shallow merge). Check the real HTML.
  • A canonical set in the root layout, so every page claims to be the home page.
  • A draft that has noindex but is still in sitemap.xml, or the other way round.
  • Thinking Disallow in robots.txt hides a page from search results.
  • Slow generateMetadata on a dynamic page. Bots wait for it.
  • Fetching the same data in generateMetadata and in the page without cache().
  • Remote images without width and height, which only fail in dev.
  • Preloading every image, so nothing is a priority.
  • Using priority in new code. It is deprecated in Next.js 16.
  • Importing a Google font in a client component on every page, instead of once in the layout.
  • Testing an OG card only on localhost. Chat apps cannot reach it. Test with the real deployed URL.

Try it

Use any Next.js app, with next build and next start.

  1. Add metadata with a title and description to app/page.tsx. Use curl -s localhost:3000 | grep -o '<title>[^<]*'. What do you see?
  2. In the root layout, set title: { default: 'My Site', template: '%s | My Site' }. In a child page set title: 'Docs'. What is the final title?
  3. Set openGraph: { siteName: 'My Site' } in the layout and openGraph: { title: 'Docs' } in the page. Does the page's HTML still have og:site_name?
  4. Make app/sitemap.ts and app/robots.ts. Open /sitemap.xml and /robots.txt.
  5. Make app/opengraph-image.tsx that returns an ImageResponse. Look at the og:image tags in the head. Open the image URL in a browser.
  6. Add a local PNG with a static import into <Image>. Look at the <img> tag. Where did width and height come from?
  7. Call /_next/image?url=...&w=640&q=75 with curl -H "Accept: image/webp" and compare the size with the original file.
  8. Use next/font/google for any font. Open DevTools, Network. Which hosts do you see?
  9. Make a dynamic page whose generateMetadata waits 800 ms. Request it with curl -A "Twitterbot/1.0" and with a normal user agent. Where is the <title> each time?

Answers

  1. <title> followed by your title. A static page has it in the first bytes.
  2. Docs | My Site.
  3. No. The page's openGraph replaced the layout's whole openGraph object (shallow merge). Copy shared fields into the page, or share a variable.
  4. A list of <url> entries, and a text file with User-Agent, Allow/Disallow and a Sitemap: line.
  5. og:image, og:image:type, og:image:width, og:image:height (and alt if you exported it). The URL has a hash after ?.
  6. Next.js read them from the image file at build time, because of the static import.
  7. The WebP is much smaller than the PNG for most graphics. The size depends on the image.
  8. Only your own host. The font was downloaded at build time.
  9. For the bot, in the <head> after a delay. For a normal browser, later in the stream (after the </head>).

Interview questions

How do you set page metadata in the App Router? (Must know)

I export a metadata object, or a generateMetadata function if it depends on data, from a page.tsx or layout.tsx. They only work in Server Components. Next.js builds the <head> tags from them. I put site-wide defaults, a title template and metadataBase in the root layout, and each page sets its own title, description and canonical. I also check the real HTML with curl, not only the code.

What is metadataBase, and what happens if you forget it? (Senior signal)

It is the base URL that Next.js uses to turn relative URLs in metadata, such as the canonical link and the share image, into full URLs. If you forget it, the build does not fail. It prints a warning and uses http://localhost:3000, so your Open Graph image URL would point to localhost and chat apps cannot load it. I take the base URL from an environment variable so each environment has the right one.

A page sets openGraph and the site name from the layout disappears. Why? (Trap)

Metadata from different segments is merged shallowly. A later segment's openGraph replaces the whole openGraph object from the earlier one, rather than merging its fields. I saw it: og:site_name was present on a page without its own openGraph, and gone on the blog post that set one. The fix is to spread a shared object into each page's openGraph, and to verify the output.

How do you avoid fetching the same data in generateMetadata and in the page? (Senior signal)

I wrap the data function in React's cache(), so it runs once per request even if two places call it. With a plain fetch to the same URL, Next.js memoizes it already. I counted the calls in my build: 6 database calls without cache() for three posts, 3 with it.

How do you make a share image for each blog post? (Must know)

I add an opengraph-image.tsx in the route folder. It exports alt, size and contentType, and a default function that returns an ImageResponse built from JSX. Next.js adds the og:image and twitter:image tags with a hash in the URL, so changes are picked up. The image is rendered with Satori, so only flexbox and a subset of CSS work, and the bundle limit is 500 KB. I fetched mine and got a 1200 by 630 PNG of about 25 KB.

How do sitemap.ts and robots.ts work, and what is the difference between robots.txt and noindex? (Must know)

Each is a special file that returns an object. Next.js turns it into /sitemap.xml and /robots.txt. The sitemap lists URLs I want found, and I keep drafts out of it. robots.txt asks well-behaved crawlers not to visit some paths. It does not hide a page, and the URL can still appear in results. To keep a page out of search results I use robots: { index: false }, which gives <meta name="robots" content="noindex">, and the crawler must be allowed to visit the page to see it. Neither is security.

Does streaming affect the <title> for SEO? (Senior signal)

A static page has its title in the first bytes. On a dynamic page, Next.js streams the metadata for normal browsers, so the page starts fast and the title comes later in the stream. For HTML-limited bots, such as Twitterbot, it waits for generateMetadata and puts the title in the <head>. I measured both: for Chrome the first byte came at 32 ms and the title was after the </head>. For Twitterbot the first byte came at about 820 ms and the title was inside the head. So slow generateMetadata slows bots, and I keep it fast.

Why use next/image instead of <img>? (Must know)

It serves a size that fits the screen (srcSet and sizes), a smaller format (AVIF or WebP, chosen from the Accept header), lazy-loads by default, and reserves space with width and height, which prevents layout shift. In my test, a 156 KB PNG came back as 4.5 KB AVIF at 640 px wide. Without a size, a slow image caused a layout shift of 0.03 and with the size it was 0. For the one LCP image I use preload or fetchPriority="high", not for every image.

Is the image optimizer safe to leave open? (Trap)

It is not open. In Next.js 16 you must list the allowed remote hosts with remotePatterns, the widths come from a fixed list, and qualities must be listed (the default is only 75). I tested a width that was not on the list, a quality that was not allowed, and a host that was not allowed, and each returned 400. The old domains option is deprecated because it is too loose.

What does next/font do that a normal <link> to Google Fonts does not? (Senior signal)

It downloads the font at build time and serves it from my own site, so the page makes no request to Google. I loaded a page with a Google font and the only host contacted was my own. It also adds a preload link and generates a fallback font with size-adjust and metric overrides that match the real font, so the swap barely moves the text. That gives me better privacy, one less connection, and less layout shift.

Why can next/image pass in production but fail in dev? (Trap)

Because the strict check on width and height for remote images, and the check on unconfigured hosts, throw errors in the dev server. In my production build, the same two pages built and returned 200, one with an <img> that had no dimensions and one whose image URL the optimizer then refused with 400. I look at the dev console for these errors, and I also test the production build.

How would you improve LCP and CLS on a Next.js marketing page? (Senior signal)

For LCP: find the LCP element (often the hero image), serve it with next/image with preload or fetchPriority="high" and a correct sizes, and make the page fast to first byte with static rendering or a small streamed shell. For CLS: give every image its size, use next/font so the fallback matches, and make Suspense fallbacks the same height as the content. For INP: keep client JavaScript small. And I measure with real-user data, not only Lighthouse.

Key takeaways

  • Metadata lives in the <head>. In the App Router you export metadata or generateMetadata from a Server Component, and viewport is a separate export.
  • Set metadataBase once. Without it, social image URLs fall back to localhost and only a warning is printed.
  • Metadata from segments merges shallowly. A page's openGraph replaces the layout's. Read the real HTML to catch it.
  • Use cache() so that generateMetadata and the page share one data call (I counted 6 calls become 3).
  • On dynamic pages, browsers get streamed metadata and bots get the title in the <head> after a wait. Keep generateMetadata fast.
  • opengraph-image.tsx, sitemap.ts and robots.ts turn into real files and tags. Disallow does not hide a page, and noindex needs the page to be crawlable.
  • next/image served 4.5 KB instead of 156 KB, and a set size removed layout shift. Allow lists make the optimizer safe. Dev errors do not always appear in production.
  • next/font self-hosts the font (no Google request at runtime) and adds a size-matched fallback.
  • Use preload only for the LCP image. priority is deprecated.

What's next

Next: Environment, Configuration, Deploying and Production Habits. Your metadata needs the real site URL, and your app needs secrets that must never reach the browser. You will see which environment variables are inlined into the JavaScript at build time, which are read at run time, what next.config can do, and how I built and ran the app as a standalone Node server and as a Docker image. Practise now: build a small page with a static import <Image>, then ask the optimizer for it with different Accept headers and compare the byte sizes.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series