Type something to search...
Next.js generateViewport

Next.js generateViewport

If you've ever squinted at a site on your phone because the text rendered tiny and zoomed-out, you've felt the absence of a proper viewport meta tag. Browsers need to be told how to size a page on a mobile screen, and for a long time that meant hand-writing a <meta name="viewport"> tag and hoping you got the syntax right. Next.js folds that responsibility into the App Router's Metadata API, giving you a typed, code-based way to control viewport behavior instead of a string you have to memorize.

generateViewport is the dynamic half of that system. Its static sibling, the viewport object, was actually split out of the general metadata export a few versions back specifically because viewport information behaves differently from the rest of a page's metadata — it can't be streamed in after the fact, and mixing it with things like Open Graph tags made the API harder to reason about. Understanding why that split happened tells you almost everything you need to know about using generateViewport correctly.

Why Viewport Got Its Own Export

Metadata in the App Router is deliberately split into what's dynamic and what isn't. Most of <head> content — titles, descriptions, OG images — can resolve after the initial HTML ships, because none of it changes what the user sees or how the browser lays out the page. The viewport tag is different: it's read by the browser's rendering engine before a single pixel is painted, to decide the physical width and scale of the layout. If it arrived late, you'd get a flash of incorrectly-scaled content, which defeats the entire point of specifying it.

That constraint is why viewport and generateViewport are separate exports from metadata and generateMetadata, and why they follow their own rules around streaming and Cache Components (more on that below). It also explains a restriction you'll bump into immediately: both exports only work in Server Components, and you can only export one of the two from a given route segment — never both.

The Static viewport Object

If your viewport settings don't depend on anything request-specific, reach for the plain object first:

// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  themeColor: "black",
};

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

This is the common case. Theme color, initial scale, color scheme — these are almost always fixed design decisions, not something that varies per request. Reach for the dynamic function only when you have a genuine reason to.

The generateViewport Function

When you do need computed viewport values, export a function instead:

// app/blog/[slug]/layout.tsx
export function generateViewport({ params }) {
  return {
    themeColor: "...",
  };
}

The function receives the same params (and, in a page file, searchParams) that page.tsx and layout.tsx receive, typed via the PageProps or LayoutProps helpers depending on where you define it. If your viewport doesn't actually branch on any of that, the docs are explicit that you should use the static object instead — generateViewport exists for the cases where it genuinely can't be static, not as a default-to-dynamic habit.

The Available Viewport Fields

Whichever form you use, you're returning (or defining) the same set of fields.

themeColor

Controls the browser chrome color on supporting mobile browsers — the status bar and address bar tint on Android Chrome, for instance.

export const viewport: Viewport = {
  themeColor: "black",
};

You can also make it conditional on the user's OS-level color scheme preference using a media query array, which is genuinely useful if your site supports both a light and dark theme:

export const viewport: Viewport = {
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "cyan" },
    { media: "(prefers-color-scheme: dark)", color: "black" },
  ],
};

This renders as two separate <meta name="theme-color"> tags, each scoped by its media attribute, and the browser picks whichever one matches.

width, initialScale, maximumScale, userScalable

These map directly onto the classic hand-written viewport tag (width=device-width, initial-scale=1, and so on). The docs are upfront that Next.js already sets sensible defaults here, so you rarely need to touch these — but they exist for cases like disabling pinch-zoom on a kiosk-style app (userScalable: false) or handling on-screen keyboards via the less commonly used interactiveWidget option.

colorScheme

Tells the browser which native color schemes your page supports, which affects things like default form control styling and scrollbar colors before your own CSS even loads:

export const viewport: Viewport = {
  colorScheme: "dark",
};

Why Viewport and Cache Components Don't Mix Casually

This is the part of the docs that's easy to skim past but genuinely matters if you're on a Cache Components-enabled project. Regular page metadata can stream in after the initial static shell renders — nobody notices if the <title> tag arrives a beat later. Viewport can't. Because it governs the initial layout, the browser needs it before paint, which means if generateViewport reads runtime-only data (cookies(), headers(), dynamic params, searchParams) or does an uncached fetch, the entire route has to defer to request time — there's no partial-render option the way there is for streamed metadata.

That leaves you with three real choices when viewport genuinely needs external data:

Cache external, non-runtime data with use cache. If the data comes from a database or API rather than the request itself, this is almost always the right move:

// app/layout.tsx
export async function generateViewport() {
  "use cache";
  const { width, initialScale } = await db.query("viewport-size");
  return { width, initialScale };
}

Wrap the document in Suspense if it truly needs request data. This signals that the whole route is dynamic, but at least the app shell ships immediately while the rest streams in behind it:

// app/layout.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

export async function generateViewport() {
  const cookieJar = await cookies();
  return {
    themeColor: cookieJar.get("theme-color")?.value,
  };
}

export default function RootLayout({ children }) {
  return (
    <Suspense>
      <html>
        <body>{children}</body>
      </html>
    </Suspense>
  );
}

Opt the segment out of instant-navigation validation with instant = false. This is the blunter option — the route renders fully on every request, and navigation to it blocks until that render finishes. It only affects the segment that exports it; descendant segments still follow the default validation.

If you're building something like a documentation site where 95% of routes are static but one dashboard section genuinely needs a per-user viewport, the practical pattern is to isolate that section with its own root layout rather than letting one dynamic generateViewport call force dynamic rendering across your whole app. Multiple root layouts exist precisely so you can draw that line cleanly instead of dynamic-ifying everything by accident.

Common Mistakes

Reaching for generateViewport out of habit. If none of your viewport values actually change per route or per request, you're adding an unnecessary function call and, worse, potentially opting a route into dynamic rendering for no reason. Default to the static object; promote to the function only when you have params or search params to branch on.

Forgetting you can't export both. Because viewport and metadata used to be one object, it's an easy habit to fall back into trying to bundle viewport fields into your metadata or generateMetadata export. They're separate exports now, and mixing them silently does nothing — the fields need to live under their own viewport or generateViewport export to take effect. If you're migrating an older project, Next.js ships a metadata-to-viewport-export codemod specifically for this.

Reading cookies or headers without realizing the render cost. It's easy to reach for cookies() inside generateViewport to theme based on a user preference, not realizing that doing so — without a Suspense boundary or instant = false — will make the entire route block on that read at request time, because viewport can't stream independently the way other metadata can.

Types

The Viewport type from next gives you IDE-level safety on both the static object and the function's return value:

import type { Viewport } from "next";

export const viewport: Viewport = {
  themeColor: "black",
};

For a function that reads segment props, type it the same way you'd type a page or layout component:

import type { Viewport } from "next";

type Props = {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

export function generateViewport({ params, searchParams }: Props): Viewport {
  return {
    themeColor: "black",
  };
}

export default function Page({ params, searchParams }: Props) {}

If you're on plain JavaScript rather than TypeScript, JSDoc gets you the same autocomplete without a build step:

/** @type {import("next").Viewport} */
export const viewport = {
  themeColor: "black",
};

Key Takeaways

QuestionAnswer
Static values, no request dependency?Export the viewport object
Values depend on params/searchParams?Export the generateViewport function
Can I export both viewport and metadata-style fields together?No — they're separate exports, and always have been since the split
Can viewport stream in after the initial paint like other metadata?No — it must resolve before paint, which is the core constraint driving everything else on this page
Viewport depends on external (non-runtime) data?Wrap the fetch in use cache
Viewport genuinely depends on runtime data (cookies, headers)?Wrap the document in <Suspense>, or opt out with instant = false for just that segment

generateViewport is a small API on the surface, but the constraint underneath it — that viewport can't stream — is the same constraint that shapes a lot of Next.js's rendering model more broadly. Once you internalize that viewport is special because it has to be known before paint, the rest of the rules here stop feeling arbitrary and start feeling like the only sensible way to build the feature.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper