generateViewport

Return viewport and theme-color settings that depend on route params or fetched data.

Signature
export function generateViewport({
  params,
  searchParams,
}: {
  params: Promise<Record<string, string | string[]>>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}): Viewport | Promise<Viewport>
  • Added in Next.js v14.0.0

Parameters

ParameterTypeDescription
$paramsoptionalPromise<Record<string, string | string[]>>Dynamic route params from the root segment down to the segment this is exported from.
$searchParamsoptionalPromise<{ [key: string]: string | string[] | undefined }>The current URL's query string. Available in page.js segments only.

Return value

Viewport | Promise<Viewport>A Viewport object. width, initialScale, maximumScale, userScalable and interactiveWidget are serialised into the viewport meta tag; themeColor and colorScheme become their own meta tags.

Errors

Exporting both the viewport object and generateViewport from the same route segment is an error. With Cache Components enabled, reading runtime data such as cookies() here raises a blocking-prerender error because viewport cannot be streamed.

Changelog

  • PHP v14.0.0viewport and generateViewport introduced; viewport, themeColor and colorScheme were deprecated as metadata fields at the same time. The metadata-to-viewport-export codemod migrates them.

Next.js 14 pulled viewport, themeColor and colorScheme out of the metadata object into their own export. generateViewport is the dynamic half of that pair: use it only when the values depend on the route — a per-tenant brand colour, a theme picked from a param. If they are constants, export the static viewport object instead.

What trips people up

  • Next.js already emits <meta name="viewport" content="width=device-width, initial-scale=1"> on every page. You rarely need to restate it, and overriding it with userScalable: false blocks pinch-zoom for everyone.
  • Unlike metadata, viewport cannot stream: it affects the first paint, so anything it awaits blocks the document. With Cache Components enabled, reading cookies() here raises an error unless you wrap the layout's <body> in <Suspense> or set instant = false; if the data is external but not request-specific, mark the function 'use cache'.
  • Server Components only, and one export per segment — viewport or generateViewport, never both.

themeColor accepts an array of { media, color } pairs, which is how a light and a dark browser chrome colour are declared together.

Example

Example · typescript
import type { Viewport } from 'next'
import { getTenant } from '@/app/lib/tenants'

type Props = {
  params: Promise<{ tenant: string }>
}

export async function generateViewport({ params }: Props): Promise<Viewport> {
  const { tenant } = await params
  const { lightColor, darkColor } = await getTenant(tenant)

  return {
    width: 'device-width',
    initialScale: 1,
    themeColor: [
      { media: '(prefers-color-scheme: light)', color: lightColor },
      { media: '(prefers-color-scheme: dark)', color: darkColor },
    ],
  }
}

export default function TenantLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section>{children}</section>
}

When to use it

  • Give each white-label tenant its own browser chrome colour from the route param.
  • Declare separate theme colours for light and dark browser UI.
  • Lock a kiosk or embedded route to device width with zoom disabled.
  • Set colorScheme so form controls render dark on a dark-only route.

Frequently asked questions

Why is themeColor not working in my metadata export?

themeColor, colorScheme and viewport were deprecated as metadata fields in Next.js 14 and moved to the separate viewport export, so setting them on metadata now emits nothing. Move them into export const viewport or generateViewport; the metadata-to-viewport-export codemod does it automatically.

Should I use viewport or generateViewport?

Use the static viewport object unless the values depend on request information — it is simpler and cannot block the render. Reach for generateViewport only when the theme colour or scale comes from route params, a database or a cookie, and remember a segment may export one or the other, not both.

Can generateViewport read cookies or headers?

It can, but the viewport tag affects first paint and cannot be streamed, so the whole document blocks until it resolves. With Cache Components enabled this raises an error unless you signal the choice explicitly, either by wrapping the root layout's body in Suspense or by setting instant = false on the segment.

More examples

The static export, and what it renders

The four layout fields collapse into a single viewport meta tag, while colorScheme and themeColor each get their own. Prefer this static form whenever the values are literals — it costs nothing at render time. userScalable: false is shown for completeness; it disables pinch-zoom and is an accessibility regression on most sites.

Example · typescript
// app/layout.tsx
import type { Viewport } from 'next'

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
  colorScheme: 'dark',
  themeColor: 'black',
  // Also supported but less commonly used
  // interactiveWidget: 'resizes-visual',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

// <head> output:
// <meta name="viewport"
//   content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
// <meta name="color-scheme" content="dark" />
// <meta name="theme-color" content="black" />

Cached external data, and the runtime-data escape hatch

The first function reads a database but nothing request-specific, so 'use cache' keeps the route prerenderable. The second reads a cookie, which defers viewport to request time; because viewport cannot stream, the render would block, and Cache Components rejects that by default. instant = false opts just this segment out of instant-navigation validation — the alternative is wrapping the root layout's <body> in <Suspense> so the shell ships first.

Example · typescript
// app/layout.tsx — external data, not request data
import type { Viewport } from 'next'

export async function generateViewport(): Promise<Viewport> {
  'use cache'
  const { width, initialScale } = await db.query('viewport-size')
  return { width, initialScale }
}

// app/dashboard/layout.tsx — genuinely per-request
import { cookies } from 'next/headers'
import type { Viewport } from 'next'

export const instant = false

export async function generateViewport(): Promise<Viewport> {
  const cookieJar = await cookies()
  return {
    themeColor: cookieJar.get('theme-color')?.value,
  }
}

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section>{children}</section>
}

Discussion

  • Be the first to comment on this lesson.