@decocms/nextjs
v7.15.0
Published
Deco framework binding for Next.js App Router
Downloads
1,883
Keywords
Readme
@decocms/nextjs
Deco framework binding for Next.js App Router — the Next.js sibling of
@decocms/tanstack. Three surfaces, composed together:
@decocms/nextjs/config—withDeco(nextConfig), anext.configwrapper that adds the rewrites andtranspilePackagesDeco's admin protocol needs.@decocms/nextjs/routeHandlers—createDecoRouteHandlers({ setup }), a single catch-all dispatcher for the whole Studio admin protocol (decofile read/reload, meta schema, invoke, live previews).@decocms/nextjs/setup—createNextSetup(options), a one-call site bootstrap: the Next.js analogue of Vite'screateSiteSetup+createAdminSetup+import.meta.glob.
This document is the complete recipe a new Next.js site follows to wire all three together. Every code block below is meant to be copied, not paraphrased.
Install
bun add @decocms/nextjs @decocms/blocks @decocms/blocks-adminnext, react, and react-dom are peer dependencies — the site already
has them.
1. next.config — withDeco
withDeco adds three rewrites (mapping the Studio-protocol URLs Next.js
cannot express as route segments onto /deco/*, where the catch-all route
below serves them) and appends the three @decocms/* packages (which ship
raw TypeScript, not a prebuilt dist/) to transpilePackages.
TypeScript (next.config.ts):
import type { NextConfig } from "next";
import { withDeco } from "@decocms/nextjs/config";
const nextConfig: NextConfig = {
// ...your own config
};
export default withDeco(nextConfig);CommonJS (next.config.js) — most Next.js sites still use this form:
/** @type {import('next').NextConfig} */
const { withDeco } = require("@decocms/nextjs/config");
const nextConfig = {
// ...your own config
};
module.exports = withDeco(nextConfig);withDeco merges with a rewrites/transpilePackages you already have —
it never replaces them. If your own rewrites() returns the array form,
Deco's rewrites are prepended; if it returns the { beforeFiles, afterFiles,
fallback } object form, Deco's rewrites are prepended to beforeFiles.
2. The catch-all route
Mount createDecoRouteHandlers at app/deco/[[...deco]]/route.ts — this
one file serves the entire admin protocol (decofile, meta, invoke, live
previews) for both the rewritten public URLs (/.decofile, /live/_meta,
/live/previews/*) and their /deco/* destinations directly.
// src/app/deco/[[...deco]]/route.ts
import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
import { ensureSetup } from "../../../deco/setup";
export const dynamic = "force-dynamic";
export const { GET, POST, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup });dynamic = "force-dynamic" is required — this route must never be
statically cached (decofile reloads, invoke calls, and previews all need a
fresh response per request).
Route-handler import rule: subpaths, never the root barrel
Always import from @decocms/nextjs/routeHandlers (or /config,
/setup) in a route.ts file. Never import { ... } from "@decocms/nextjs"
there.
App Router route handlers evaluate their entire module graph against
React's react-server build, and this happens regardless of any
"use client" directive in that graph — route handlers have no client
bundle to move a "use client" module into, so the directive is simply
ignored. The root barrel (@decocms/nextjs's . export) re-exports the
render components (DecoRootLayout, SectionRenderer, DecoPageRenderer,
...), whose module graph reaches component code that uses client-only React
APIs (createContext, useContext, class components with
componentDidCatch, etc.) at module scope. Importing the root barrel from
a route file pulls that whole graph into react-server evaluation and
crashes at import time, before your handler ever runs, with errors like
"...createContext is not a function" or "Class extends value undefined
is not a constructor" — even if the route handler itself never touches
those components. The /routeHandlers, /config, and /setup subpaths
are each scoped to keep their own module graph free of component code, so
they're safe to import from anywhere, including a route file.
3. src/deco/setup.ts — createNextSetup
The codegen artifacts (sections.gen.ts, meta.gen.json) are generated into
.deco/ at the site root — the same default @decocms/blocks-cli's
generators use everywhere else (framework artifacts live in the framework's
folder, not scattered across src/). src/deco/setup.ts isn't adjacent to
.deco/, so import it through a deco/* tsconfig path alias instead of a
relative path:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"deco/*": [".deco/*"]
}
}
}// src/deco/setup.ts
import { createNextSetup } from "@decocms/nextjs/setup";
import blocks from "deco/blocksManifest.gen"; // .deco/blocksManifest.gen.ts — see below
import { sectionImports, sectionMeta, syncComponents, loadingFallbacks } from "deco/sections.gen";
export const ensureSetup = createNextSetup({
blocks,
blocksDir: false, // the manifest replaces the runtime fs read
sections: sectionImports,
conventions: { meta: sectionMeta, syncComponents, loadingFallbacks },
meta: () => import("deco/meta.gen.json").then((m) => m.default),
});Recommended block source: the static-import manifest
generate-blocks-manifest (from @decocms/blocks-cli) emits
.deco/blocksManifest.gen.ts — a module that statically imports every
.deco/blocks/*.json file (raw on-disk filename in the specifier; keys are
the filename minus .json, verbatim, exactly like
loadDecofileDirectory). Passing its default export as blocks with
blocksDir: false (as in the snippet above) makes the manifest the sole
block source and the bootstrap pure — no filesystem access.
Why this is the recommended wiring over the default blocksDir runtime
read:
- CMS content hot-reloads in
next dev. The block JSONs are part of Next's module graph, so editing one (Studio daemon write, sync, manual edit) invalidates the server module graph, re-evaluates the setup module, and rebuildscreateNextSetup's memo with the fresh content — ~120–165ms per edit, measured on a 500-block site. The runtime fs read is invisible to the bundler: edits invalidate nothing and dev serves stale content until a restart. - Deploys need no
outputFileTracingIncludeshack — the JSON is bundled into the build output instead of read from disk at runtime.
Trade-offs to know about:
- Content is baked at build time in production. Studio's
POST /.decofilestill overrides the in-memory snapshot at runtime (live preview/publish keeps working on a warm instance); the baked manifest is the cold-start baseline. - Adding or removing a block file requires re-running the generator
(content edits to existing files do not). Wire it into the site's
generatechain — see the scripts section below.
If you skip the manifest, the default blocksDir: ".deco/blocks" runtime
read still works — just without dev reload, and your deploy must ship the
directory alongside the server bundle.
Keep the manifest out of any client-reachable import graph (import it only from the server-side setup module) so 5MB of CMS JSON never lands in a client bundle.
createNextSetup returns a memoized ensureSetup() function — a
successful bootstrap is cached for the life of the warm serverless
instance; a rejected bootstrap clears the memo so the next call retries
from scratch (the triggering call still rejects with the original error).
Two call sites need ensureSetup():
The catch-all route (above) passes it as
{ setup: ensureSetup }—createDecoRouteHandlersawaits it before dispatching every admin request.The root layout (
app/layout.tsx) must await it directly before rendering, since page rendering (createDecoPage's resolver) has no setup hook of its own:// src/app/layout.tsx import { DecoRootLayout } from "@decocms/nextjs"; import { ensureSetup } from "../deco/setup"; export default async function RootLayout({ children }: { children: React.ReactNode }) { await ensureSetup(); return <DecoRootLayout siteName="my-site">{children}</DecoRootLayout>; }(
app/layout.tsxis a Server Component, not a route handler, so it's fine to import the root barrel here — see the rule above.)
NextSetupOptions at a glance
| Option | Purpose |
| --- | --- |
| blocksDir | Directory of decofile JSON snapshots (.deco/blocks by default), read with a plain fs scan at bootstrap. Pass false to skip filesystem loading entirely — the recommended setting when blocks carries the static-import manifest (see above). |
| blocks | Extra/override blocks, merged over the directory's blocks. Pass the manifest's default export here (with blocksDir: false) to make it the sole block source. |
| sections | The lazy section registry — sectionImports from generate-sections --registry (see below). |
| conventions | { meta, syncComponents, loadingFallbacks } from sections.gen.ts — wires the export const sync/layout/seo/cache/eager/clientOnly conventions (see below). |
| meta | Lazy admin meta schema loader: () => import("deco/meta.gen.json").then(m => m.default). Wire this even with a trivial schema — without it, /deco/meta (and its /live/_meta alias) 503s with "Schema not initialized". |
| renderShell | Admin preview shell config ({ css, fonts }). |
| previewWrapper | Admin preview wrapper component. |
| productionOrigins, customMatchers, onResolveError, onDanglingReference | Passed through to createSiteSetup. |
| extend | Site-specific wiring that must run after core setup (section loaders, legacy SEO key shims, curated post-processing). Receives the loaded blocks. |
4. package.json scripts — non-colliding names
Add three codegen scripts. Do not name any of these generate:schema —
FastStore sites already own that script name for their own commerce-schema
codegen, and a collision silently shadows one of the two generators
depending on script-merge order. Use these names instead:
{
"scripts": {
"generate:deco-meta": "tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts",
"generate:deco-sections": "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts --registry",
"generate:deco-blocks": "tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts"
}
}Neither script needs an --out/--out-file flag — both generators default
to .deco/, which is exactly where src/deco/setup.ts reads them from via
the deco/* path alias (see above). Only pass --out/--out-file if your
site has a reason to put the artifact somewhere else.
generate:deco-metarunsblocks-cli'sgenerate-schema.ts, which scanssrc/sections/,src/loaders/, andsrc/apps/forPropsinterfaces and emits the JSON Schema the admin's/deco/metaendpoint serves, to.deco/meta.gen.jsonby default.generate:deco-sectionsrunsgenerate-sections.tswith--registry— the flag that additionally emitssectionImports, the Next.js/webpack equivalent of Vite'simport.meta.glob("./sections/**/*.tsx")(Next has noimport.meta.globor Vite plugin, so this is generated instead of computed at build time). Without--registryyou only getsectionMeta/syncComponents/loadingFallbacks, not the lazy loader mapsetup.tsneeds for itssectionsoption. Defaults to.deco/sections.gen.ts.generate:deco-blocksrunsgenerate-blocks-manifest.ts, which emits the static-import blocks manifest (see the recommended-block-source section above) to.deco/blocksManifest.gen.tsby default. Regeneration is idempotent — an unchanged block set rewrites nothing, so no-op runs never tickle Next's file watcher.
Run the first two any time src/sections/ changes, and
generate:deco-blocks any time a block file is added or removed in
.deco/blocks/ (content edits to existing files don't need it — the static
imports pick those up by themselves). Simplest is to wire all three into a
predev/prebuild generate chain.
5. src/sections/ — the entry-file convention
Every non-test .tsx/.ts file directly under src/sections/ (recursively)
becomes a section key, keyed as site/sections/<path-relative-to-sections-dir>.
This is not opt-in — generate-sections.ts walks the whole directory
and turns every matching file into a registry entry, whether or not it
carries any convention exports. Files ending in .test.ts(x), .spec.ts(x),
.stories.ts(x), or .gen.ts(x) are the only ones excluded.
The established pattern is a thin re-export entry file per section, with
the actual component implementation living elsewhere (e.g. alongside its
own subcomponents, styles, and tests, outside src/sections/):
// src/sections/Hero.tsx — thin entry file, becomes "site/sections/Hero.tsx"
export { default } from "../components/Hero/Hero";
export type { HeroProps as Props } from "../components/Hero/Hero";This keeps src/sections/ a clean, flat index of exactly what's
CMS-addressable, instead of a directory contest between "real" component
code and registry wiring.
Convention exports
A section's entry file (or the file it re-exports from — the scanner reads
the entry file's own source, so re-exported export const conventions must
be re-declared or forwarded on the entry file itself, not just the
implementation file) can opt into these, each read as a literal
export const <name> = <value>:
| Export | Effect |
| --- | --- |
| export const sync = true | Bundled synchronously (not lazy-loaded) — for above-the-fold, always-rendered sections. |
| export const layout = true | Cached as a layout section (Header, Footer, Theme) — resolved once, not per-page. |
| export const seo = true | SEO section — its resolved props are merged into page-level SEO. |
| export const cache = "listing" | SWR-cached section loader results, keyed by the given cache name. |
| export const eager = true | Prefers eager rendering (defers only past the fold threshold). |
| export const neverDefer = true | Always eager, ignoring the fold threshold entirely. |
| export const clientOnly = true | Skips SSR — client-only rendering. |
| export function LoadingFallback | Skeleton component shown while the section loads. |
Verifying your setup
examples/nextjs-smoke in this monorepo is a minimal, real Next.js App
Router build exercising all three surfaces end to end — withDeco rewrites,
the catch-all route, createNextSetup, and a resolved page render. Use it
as a working reference if any of the above doesn't compose the way you
expect.
