`generateViewport` and Theme Color Explained

How the viewport export and generateViewport work in the Next.js App Router: theme color with light and dark variants, color scheme, scaling options, and why themeColor moved out of metadata.

8 min read

generateViewport is the dynamic form of the viewport export, which controls the viewport meta tag, the browser theme color, and the declared color scheme. Export a static viewport object when the values are fixed, and the function when they depend on the route. Both are Server Component only.

These exports were introduced in Next.js 14 and were verified here with Next.js 16.3 in an App Router project, reading tags from a production build. They belong in a layout or page file, next to the metadata export.

What you get without doing anything

Next.js always renders a default viewport tag, even for a route that exports nothing. Most projects never need to change it.

htmlhtml
<meta name="viewport" content="width=device-width, initial-scale=1" />

That default makes a responsive layout behave correctly on phones, which is the reason the tag exists. Adding the export is about the extras, most often the theme color.

A second default tag sets the character encoding, and neither of them needs an export to appear. Writing either by hand in a layout duplicates what Next.js already emits.

The viewport export exists for the fields the default does not cover, and for the rare case where the default scaling is genuinely wrong for an application.

Setting a theme color

The theme color suggests a color for the browser interface around your page, such as the address bar area on a mobile browser. Put it in the root layout so every route inherits it.

App.tsxApp.tsx
// app/layout.tsx
import type { Viewport } from 'next'
 
export const viewport: Viewport = {
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#0b0b0b' },
  ],
  colorScheme: 'light dark',
}

Every route now renders two theme color tags, each carrying its media query, plus a color scheme tag. A single string works too, and renders one tag with no media attribute.

The color scheme field is a different signal. It tells the browser which schemes your page supports, so form controls and scrollbars are drawn to match instead of staying light on a dark page.

Support is uneven

Theme color is not honored everywhere, and it is most visible in mobile browsers on Android. Treat it as a finishing touch rather than part of your design system.

Overriding it for one route

Export generateViewport instead when the value depends on the route, for example a section with its own brand color. It returns the same object shape.

App.tsxApp.tsx
// app/campaign/page.tsx
import type { Viewport } from 'next'
 
export function generateViewport(): Viewport {
  return { themeColor: '#1d4ed8' }
}
 
export default function Page() {
  return <h1>Campaign</h1>
}

Requesting that route now renders the campaign color, while the rest of the site keeps the layout value. The nearest segment wins, exactly as it does for the Metadata API.

Reach for generateViewport only when a value is computed. The documentation is explicit that a viewport which does not depend on request information belongs in the static object instead.

The function can also receive the route params, typed through the generated page or layout props helper. That is the case it was designed for, such as a theme color stored on the record the route is rendering.

Scaling fields and accessibility

The remaining fields map onto the parts of the viewport tag, and two of them are worth avoiding rather than learning.

FieldRenders as
widthwidth=device-width
initialScaleinitial-scale=1
maximumScalemaximum-scale=1
userScalableuser-scalable=no

Setting the last two to lock zoom is a common request from designers and an accessibility failure. The WCAG criterion on resizing text expects content to scale to 200 percent, and its published test rule checks that the viewport tag still allows zoom.

Leave both out. There is no layout problem worth solving by preventing someone from enlarging your text.

The themeColor that moved

Older tutorials put the theme color inside the metadata object. That stopped working in Next.js 14, and the failure mode today is a warning rather than an error.

texttext
⚠ Unsupported metadata themeColor is configured in metadata export in /vp. Please move it to viewport export instead.

That message appears during the build, naming the route, and the value renders nothing. Moving the field into a viewport export is the entire fix, and an official codemod exists for projects with many files to update.

The color scheme field made the same move at the same time, so a project migrating one usually has to migrate both.

Search your project for both names inside a metadata object before assuming the migration is done. The build prints one warning per affected route, and warnings scroll past easily in a long build log.

Two rules that fail quietly

The metadata pair stops the build when both forms are exported. The viewport pair does not, which makes it easier to get wrong.

Exporting a viewport object and a generateViewport function from the same file builds successfully, and generateViewport silently wins. If a theme color you set is being ignored, look for a leftover static object in the same file.

The second trap is the client boundary. These exports are supported only in Server Components, and adding one to a file that carries the client directive breaks the build while prerendering that route, with an internal TypeError that never mentions viewports.

Keep the page a Server Component and move the interactive part into its own file, the same boundary generateMetadata requires for the same reason: the value has to resolve on the server before the page renders.

With Cache Components enabled

Under the Cache Components model, the viewport cannot be streamed, because it affects how the page paints. A viewport that reads request data forces the document to wait for it.

The documented options are to add the cache directive inside the function when the data is external but not request-specific, or to signal an intentionally dynamic route. Both are described in the official reference, and the second involves wrapping the document body in a Suspense boundary.

If your project has not enabled that flag, none of this applies, and the static object stays the right default. The tradeoffs of turning it on are covered in Cache Components explained.

This is a good reason to keep the theme color static. A viewport that depends on a cookie turns an otherwise prerenderable route into a dynamic one, which is a large cost for a color.

Common mistakes

Each of these generateViewport and theme color mistakes is easy to ship because nothing fails loudly.

  • Leaving the theme color in the metadata object and never reading the build warning.
  • Disabling zoom to protect a layout, which fails an accessibility requirement.
  • Exporting both the object and the function, then debugging the wrong one.
  • Setting a theme color that clashes with the page background in one color scheme.
  • Treating the theme color as a guaranteed part of the design when many browsers ignore it.
  • Putting the viewport export in every page file instead of the root layout, which multiplies the places a color has to change.
Rune AI

Rune AI

Key Insights

  • Export a viewport object, or generateViewport when the value depends on route data.
  • Both are Server Component only and were introduced in Next.js 14.
  • themeColor accepts an array with media queries for light and dark variants.
  • Setting themeColor in metadata prints a build warning and renders nothing.
  • Disabling user scaling fails an accessibility requirement, so leave zoom enabled.
RunePowered by Rune AI

Frequently Asked Questions

Why is themeColor no longer part of metadata?

It moved to the viewport export in Next.js 14. Setting it in metadata now prints a build warning saying to move it, and the value is not rendered.

Do I need to set the viewport meta tag myself?

No. Next.js always emits a sensible default viewport tag, so you only add the export when you need to change it or add a theme color.

Where should the theme color live?

Usually in the root layout, so every route inherits it. Export it from a page only when one section needs a different color.

Does every browser use theme-color?

No. Support is limited, and it is most visible in mobile browsers on Android. Treat it as an enhancement rather than a design requirement.

Conclusion

The viewport export controls the viewport meta tag, the theme color, and the color scheme, and generateViewport is its dynamic form for values that depend on the route. Keep it in the root layout, prefer the static object, never disable zoom, and remember that theme color left the metadata object in Next.js 14.