Skip to content
Site volume
Colourway

Get started

The Next.js recipe

Overprint in a Next.js App Router site, step by step. Every excerpt comes from a consumer app that has to build.

  • Experimental
On this page

This recipe cannot drift: every code block on this page is pulled at build time from fixtures/next-consumer, the app the release checks install from packed tarballs and build with next build. If a step stops working there, this page stops building too.

1. Install#

The consumer app's dependencies are the site's floor versions. Each PACK is replaced by a packed tarball in the check; in your app it is the published version.

package.json (the consumer template)
{
  "name": "overprint-next-consumer",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@maxgrits/overprint-fonts": "PACK",
    "@maxgrits/overprint-generators": "PACK",
    "@maxgrits/overprint-react": "PACK",
    "@maxgrits/overprint-three": "PACK",
    "@maxgrits/overprint-tokens": "PACK",
    "@react-three/fiber": "9.7.0",
    "next": "16.2.12",
    "react": "19.2.8",
    "react-dom": "19.2.8",
    "three": "0.185.1"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "4.3.3",
    "@types/node": "24.19.2",
    "@types/react": "19.2.18",
    "@types/react-dom": "19.2.7",
    "babel-plugin-react-compiler": "1.0.0",
    "postcss": "8.5.31",
    "tailwindcss": "4.3.3",
    "typescript": "6.0.3"
  },
  "overrides": {}
}

2. The root layout#

The fonts module's variables go on <html> with the data-mk-* defaults (Process, Bone, Studio). In <head>, the pre-paint scripts run in this order: PrefsScript (the visitor's theme, volume and colourway, before the first paint, so no flash of the wrong paper), then the GL gate's script, then the motion script.

app/layout.tsx
// The site's layout recipe (60 §A.1, 50 §6.4, D-N198): the fonts module's variables on <html>, the data-mk-* defaults,
// PrefsScript first in <head>, then the GL and motion pre-paint scripts; `MotionRuntime` once in <body> (the reveals,
// the readiness flags and the caused SFX). `pnpm consumer` builds it from the packed tarballs at the site's floor
// versions.
import { fontVariables } from "@maxgrits/overprint-fonts/next";
import { MotionRuntime, motionPrepaintScript } from "@maxgrits/overprint-react/motion";
import { PrefsScript } from "@maxgrits/overprint-react/prefs";
import { glPrepaintScript } from "@maxgrits/overprint-three/gate";
import type { ReactNode } from "react";
import "./globals.css";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html
      lang="en"
      className={fontVariables}
      data-mk-colourway="process"
      data-mk-theme="bone"
      data-mk-volume="studio"
      suppressHydrationWarning
    >
      <head>
        <PrefsScript />
        <script
          // biome-ignore lint/security/noDangerouslySetInnerHtml: the system's own pre-paint script (30 §4.6)
          dangerouslySetInnerHTML={{ __html: glPrepaintScript }}
        />
        <script
          // biome-ignore lint/security/noDangerouslySetInnerHtml: the system's own pre-paint script (30 §6.4)
          dangerouslySetInnerHTML={{ __html: motionPrepaintScript }}
        />
      </head>
      <body>
        {children}
        <MotionRuntime />
      </body>
    </html>
  );
}

3. The CSS and the layer order#

The layer statement comes first, before any import: Tailwind's theme and base under the system, the site's own styles in site above it, utilities on top. Then Tailwind, the tokens as Tailwind utilities and the kit's CSS.

app/globals.css
/* The consumer contract (50 §3, D-N5): this layer statement comes first, before any @import. */
@layer theme, base, mk, site, components, utilities;

@import "tailwindcss";
/* the tokens as Tailwind utilities (bg-paper, text-ink …), after Tailwind (40 §2.11 step 3) */
@import "@maxgrits/overprint-tokens/tailwind.css";
@import "@maxgrits/overprint-react/css";
/* the 3D kit's blocks (the CSS and SVG fidelities BlocksStage prints before and without the GL stage, D-N51) */
@import "@maxgrits/overprint-three/css";
@import "./probes.css";

Tailwind 4 runs through its PostCSS plugin:

postcss.config.mjs
export default {
  plugins: { "@tailwindcss/postcss": {} },
};

@maxgrits/overprint-tokens/tailwind.css is an @theme inline block of aliases such as bg-paper and text-ink. It resets Tailwind's own palette and breakpoints, so utilities speak the system's inks and widths only.

4. The Next.js config#

The React Compiler is optional and welcome. When you use the generators, add serverExternalPackages: ["@resvg/resvg-js", "sharp"] (resvg is a native module; sharp, when installed, makes the cards smaller).

next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  reactCompiler: true,
  // the generators run on Node as published: they rasterise with resvg (a native module) and load their optional
  // peers (sharp, playwright-core) only when installed, which a bundler would try to resolve at build time (DF-53)
  serverExternalPackages: ["@maxgrits/overprint-generators", "@resvg/resvg-js", "sharp"],
};

export default config;

5. 3D through the gate#

BlocksStage is a server component. It renders the still and the figure, and its one client island, GlGate, loads the GL stage only after the gate passes (desktop, idle, motion allowed, no ?nogl). Your first-load JavaScript holds no three, no r3f and no cannon: the consumer check fails the build if it does.

app/page.tsx
// The consumer smoke page (50 §6.4): the layer precedence probes (50 §3 item 5), both preloaded fonts in use, the
// tokens, and the 3D stage behind its gate. tools/consumer/check.ts reads the ids below; `/` must ship no three,
// r3f or cannon code in its first-load chunks.
import { BlocksStage } from "@maxgrits/overprint-three/gate";

export default function Page() {
  return (
    <main>
      <h1 id="display-sample" style={{ fontFamily: "var(--mk-font-display)" }}>
        Overprint consumer fixture
      </h1>
      <p id="text-sample">Body copy in the text family, from the packed fonts module.</p>
      <div id="probe-utility" className="probe-component ps-[23px]">
        A Tailwind utility beats a component property.
      </div>
      <div id="probe-site" className="probe-component probe-site">
        The site layer beats a component property.
      </div>
      <h2 id="probe-preflight" className="probe-heading">
        A component property beats Tailwind preflight.
      </h2>
      <BlocksStage label="The MAKR blocks" />
    </main>
  );
}

6. Open Graph images#

opengraph-image.tsx returns cardResponse(template, input) from @maxgrits/overprint-generators/og, on the Node runtime, and next build lists the route as static. The consumer check reads the card back: prerendered, a PNG at 1200 × 630, and an og:image tag on /.

app/opengraph-image.tsx
// The Open Graph card (40 §5 G2, §2.11 step 6): the generators' `home` card, rendered once by `next build` on the
// Node runtime and served as a static PNG. tools/consumer/check.ts reads it: prerendered, `image/png`, 1200 × 630.
import { cardAlt, cardResponse } from "@maxgrits/overprint-generators/og";

const input = { title: "Overprint consumer fixture", kicker: "Printed by the kit" };

export const alt = cardAlt("home", input);
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default function OpengraphImage(): Promise<Response> {
  return cardResponse("home", input);
}

7. Verify#

Run next build. The Open Graph routes list as static, the first-load JavaScript of / stays inside the 3D kit's budget, and the layer probes resolve in order: a utility beats the site layer, the site layer beats a component, a component beats Tailwind's preflight.

app/probes.css (the layer probes the check reads)
/* The layer precedence probes (50 §3 item 5, D-N5): one rule in the system's components layer, one in the site
   layer. check.ts expects utility 23px > site 17px > component 11px, and the component's 13px over preflight's 0. */
@layer mk.components {
  .probe-component {
    padding-inline-start: 11px;
  }

  .probe-heading {
    margin-block-start: 13px;
  }
}

@layer site {
  .probe-site {
    padding-inline-start: 17px;
  }
}