generateViewport
Return viewport and theme-color settings that depend on route params or fetched data.
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
| Parameter | Type | Description |
|---|---|---|
$paramsoptional | Promise<Record<string, string | string[]>> | Dynamic route params from the root segment down to the segment this is exported from. |
$searchParamsoptional | Promise<{ [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 withuserScalable: falseblocks 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 setinstant = false; if the data is external but not request-specific, mark the function'use cache'. - Server Components only, and one export per segment —
viewportorgenerateViewport, 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
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.
// 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.
// 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