feat: @decocms/nextjs for v8: draft proxy and pages helpers, its own draft names and Studio origins, withDeco() and a platform-neutral image loader
Stacked on #696 (feat/tanstack-v8). Adds packages/nextjs, @decocms/nextjs for v8: small helpers for any Next.js App Router app, with no hosting assumptions (no CDN, ISR or platform rules). The site calls them from its own proxy, layout, pages and next.config. It depends only on @decocms/blocks: never on @decocms/tanstack, never on an @decocms/apps-* package (exports.test.ts checks both).
What's in it
| Import | Contents |
|---|---|
@decocms/nextjs/proxy | decideDraft(cms, request, { site, previewHosts }), draftRequestHeaders (adds x-deco-draft, removes a forged copy), rewriteToDraftRoute (/_draft), applyDraft (cookie plus draftResponseHeaders()), isDraftRoutePath, draftProxy. No React, no next/headers, no block map. |
@decocms/nextjs/server | bindDraft(cms, { searchParams, site, previewHosts }): reads the param, then x-deco-draft, then the cookie, with the same host allowlist. It reads no header when previews are off, so a static page stays static. Also activeDraftPointer and contentClient(cms). The draft lives in React's cache(). |
@decocms/nextjs | DraftPreviewIndicator (renders DraftBadge for the bound draft), DraftBadge, and StudioBridge: @decocms/blocks/preview's bridge with trustedOrigins set to the package's STUDIO_ORIGINS, so the layout still writes <StudioBridge site="my-store" />. Also the package's own draft names and Studio origins (below). |
@decocms/nextjs/config | withDeco(nextConfig, { draftHeaders, studioOrigins }): v7's two draft header rules go first, and STUDIO_ORIGINS goes into allowedDevOrigins. No rewrites and no image rules. Plain CommonJS shipped from src, so next.config.js can require() it. |
@decocms/nextjs/image-loader | createDecoImageLoader(imageUrl, { fallback }) and nextImageOptimizer. Platform-neutral: the site composes its platform's helpers into the ImageUrl (below). |
Dev mode (PO decision 1, 2026-10-15). isNextDev is gone. createCMS reads NODE_ENV itself (#693), so a Next site passes only { content } and next dev keeps serving the local content; createCMS({ dev }) still overrides it. The binding smoke app and the README use the default. After the change the smoke app builds, type-checks and passes its matrix (21 checks on next start), and the 109 package and root tests/ tests pass.
Edit shortcut (PO decision 4). StudioBridge passes its props through, so Next sites get blocks' default: the "." shortcut is on unless the layout passes editShortcut={false}. The README says so.
Draft names and Studio's origins (PO review of #693). @decocms/blocks no longer exports them; each binding owns a copy. This package's is src/draft.ts (DRAFT_PARAM __draft, DRAFT_COOKIE __deco_draft, DRAFT_HEADER x-deco-draft, draftResponseHeaders) and src/studio.ts (STUDIO_ORIGINS, STUDIO_FRAME_ANCESTORS, isStudioOrigin), with the same values as @decocms/tanstack's and never imported from it. The proxy, drafts.ts and /server use it, config.test.ts holds withDeco's CommonJS copy equal to it, and the root exports it for a site's own code.
Draft hosts (D1). v7's readAllowedHosts / isDraftHostAllowed / isDraftPreviewEnabled logic is ported unchanged into this package (src/drafts.ts), with the same cases as @decocms/tanstack:
- Allowed: the preview hosts the site declares (the
CMSblock's; none when it lists none, as in v7), plus<site>.deco.siteand<site>.deco-cx.workers.dev. With no list and nosite, previews are off andbindDraftnever reads the request. previewHosts: [...]replaces the block's list;previewHosts: "none"turns previews off everywhere.- The host checked is the visitor's:
x-forwarded-host, elsehost.
Images (PO review of #694). The loader knows no commerce platform. A Next storefront's loaderFile composes the helpers itself:
createDecoImageLoader(createImageUrl({ providers: [
vtexCatalogWebp(), vtexFileManager({ via: "query" }), // @decocms/apps-vtex/image
decoCdn({ quality: "high", fit: "contain", layout: "quality-last" }), // @decocms/blocks/image
] }), { fallback: nextImageOptimizer });
tests/nextjs-image-loader.golden.test.ts (in the root tests/, because the package depends on no apps-*) runs exactly that composition over the next/image golden URLs of @decocms/blocks and @decocms/apps-vtex: 1,212 cases (via: "thumbor" and via: "query"). The only changed cases are the 60 of the PO-approved fix 2 from #694: an already-resized Deco CDN URL in content is re-resized from its src, where it used to become src=image. The fixture marks each of them with before and reason, and the test asserts that nothing else changed (3af7b46f). A mutation check (swapping via) makes it fail.
Approved changes (PO, 2026-10-14)
- Fix 1 (a site change, not this package): the storefront whose loader still uses
vtexFileManager({ via: "query" })moves tovia: "thumbor". VTEX's?width=&aspect=trueresizer has answered 500 for raster files since 2026-10-07.next-queryin the golden test still pins the package'svia: "query"output, which is unchanged. - Fix 2 (
@decocms/blocks/image, #694, merged here): see above. The README and CLAUDE.md say so.
Tests
- 80 package tests: the allowlist matrix; proxy decisions, rewrites and headers against real
NextRequest/NextResponse;bindDraftorder and gating; the badge; the bridge renders blocks' script with exactly Studio's four origins and passeseditShortcut/nonce; the moveddraft.test.ts/studio.test.ts;withDeco's names held equal to the package's; the loader's resize/fallback rules; the exports map and esbuild graphs (/proxyhas no React ornext/headers;/image-loaderis pure); no@decocms/tanstackor@decocms/apps-*dependency or import. examples/nextjs-binding-smoke(Next 16,next build+next start) runs a 23-point matrix against a local draft server, all passing: the draft content on the draft route, no-store and noindex, the cookie and how it ends, the allowlist on a Deco host vs. any other host, a forged header ignored, a direct/_drafthit gets a 404, the Studio bridge and the origins it answers (new), the image loader insrcset, and a proxy bundle with no block map and no React. The example'sImageUrlis nowdecoCdn({ fit: "contain" }), since blocks' default fit iscover. A CI job runs it.- After the fix-2 merge: the next/image golden test, the image-loader tests and every root
tests/file pass. Full gates for the stack are at the top (#700). bun run check,bun run build,skills:checkandskills:readme:checkpass.bun run test: 2,361 passed, 2 failed: the known DS-2 indelivery.cli.test.ts, and a timeout inprotocol/storage/fs/git.test.tsunder full-suite load (that file passes 21/21 alone).
Validated locally on the two Next storefronts (before the PO review; not pushed)
For each site: I packed this branch, removed the vendored draft helpers, badge, mark and bridge, replaced the hand-written next.config draft rules with withDeco, built the loader on the image helpers, and added dev. Each built with next build --webpack under 2 GB, its jest suite passed, and a header matrix on next start passed (22/22). These local site branches were not repacked after the review: the site now imports vtexCatalogWebp/vtexFileManager from @decocms/apps-vtex/image, and the golden test above stands for the URL check, now byte-identical for both storefronts' loaders (the file-manager-to-Thumbor change for the store on via: "query" is now PO-approved, as a site change).
| Lines (before review) | |
|---|---|
| A storefront | ā924 / +115 |
| Another storefront | ā887 / +165 (the proxy gets its own CMS handle with no block map) |
For the PO
- New example, not
examples/nextjs-smoke. That example is the docs' Next.js guide, andguides.cli.test.tsnx-02 pins it to@decocms/blocksalone. The package's smoke app isexamples/nextjs-binding-smoke, liketanstack-worker-smoke. The docs' Next.js page needs updating before nx-02 can change. - Peer
next: ^16.0.0, as the plan says. Both storefronts are on 16. Next 15 isn't tested. siteadds the Deco hosts, as in v7 when the site name was known.- Next sets
Varyitself on the draft route.Cache-Control: no-store, privateand noindex do arrive;Vary: Cookiedoesn't. DecoImageis not shipped. A Server Component can't pass theImageUrlfunction to a client wrapper, and both storefronts useloaderFileanyway.- A Next quirk:
next start -H 127.0.0.1makes Next treat a proxy rewrite as an external URL. The smoke app binds the default host. Production hosts aren't affected. - Release config and npm commands are in #699.
https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
StudioPage (2026-10-16, aa2e0431, ec218311)
@decocms/nextjsre-exportsStudioPage. A CMS page renders<StudioPage pathTemplate={match.entry.path} />, so "." opens that page in Studio: the root layout stays across navigations and can't know it. It's a<meta>React puts in the head, so the page's markup doesn't change. The README shows where it goes.StudioBridgepasses the newpageprop through like the others.
Main refresh (2026-10-17, 73b9b98d)
- Merged P4
c2ac0c96into this branch (normal merge, no conflicts), then merged up the stack to M2 #707. - Gates at the top of the stack (M2
5131a3c1):bun run check,bun run build,skills:check,skills:readme:checkpass.bun run test(2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run. - CI on this PR's new head: all checks pass.
š¤ Generated with Claude Code