feat: @decocms/nextjs for v8: draft proxy and pages helpers, its own draft names and Studio origins, withDeco() and a platform-neutral image loader

TL
2h ago
+4168-43

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

ImportContents
@decocms/nextjs/proxydecideDraft(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/serverbindDraft(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/nextjsDraftPreviewIndicator (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/configwithDeco(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-loadercreateDecoImageLoader(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 CMS block's; none when it lists none, as in v7), plus <site>.deco.site and <site>.deco-cx.workers.dev. With no list and no site, previews are off and bindDraft never 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, else host.

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 to via: "thumbor". VTEX's ?width=&aspect=true resizer has answered 500 for raster files since 2026-10-07. next-query in the golden test still pins the package's via: "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; bindDraft order and gating; the badge; the bridge renders blocks' script with exactly Studio's four origins and passes editShortcut/nonce; the moved draft.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 (/proxy has no React or next/headers; /image-loader is pure); no @decocms/tanstack or @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 /_draft hit gets a 404, the Studio bridge and the origins it answers (new), the image loader in srcset, and a proxy bundle with no block map and no React. The example's ImageUrl is now decoCdn({ fit: "contain" }), since blocks' default fit is cover. 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:check and skills:readme:check pass. bun run test: 2,361 passed, 2 failed: the known DS-2 in delivery.cli.test.ts, and a timeout in protocol/storage/fs/git.test.ts under 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, and guides.cli.test.ts nx-02 pins it to @decocms/blocks alone. The package's smoke app is examples/nextjs-binding-smoke, like tanstack-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.
  • site adds the Deco hosts, as in v7 when the site name was known.
  • Next sets Vary itself on the draft route. Cache-Control: no-store, private and noindex do arrive; Vary: Cookie doesn't.
  • DecoImage is not shipped. A Server Component can't pass the ImageUrl function to a client wrapper, and both storefronts use loaderFile anyway.
  • A Next quirk: next start -H 127.0.0.1 makes 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/nextjs re-exports StudioPage. 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.
  • StudioBridge passes the new page prop through like the others.

Main refresh (2026-10-17, 73b9b98d)

  • Merged P4 c2ac0c96 into 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:check pass. 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

https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu