diff --git a/hooks/mod.ts b/hooks/mod.ts
index 6de76d44a..7c0635b73 100644
--- a/hooks/mod.ts
+++ b/hooks/mod.ts
@@ -8,6 +8,7 @@ export {
addAllowedQS,
addBlockedQS,
addBlockedQS as unstable_blockUseSectionHrefQueryStrings,
+ computeRenderCb,
useSection,
} from "./useSection.ts";
export { useSetEarlyHints } from "./useSetEarlyHints.ts";
diff --git a/hooks/useSection.ts b/hooks/useSection.ts
index cfbe5d0f6..bab1c2255 100644
--- a/hooks/useSection.ts
+++ b/hooks/useSection.ts
@@ -94,6 +94,36 @@ const createStableHref = (href: string): string => {
return hrefUrl.href;
};
+export interface RenderCbInput {
+ revision: unknown;
+ vary: unknown;
+ href: string;
+ deploymentId?: string;
+}
+
+/**
+ * Cache-bust value for `/deco/render` URLs. Single source of truth shared by
+ * `useSection` (web partials) and JSON serialization consumers (e.g. the
+ * website app's ?renderJson handler) — both sides MUST produce identical
+ * values or web/JSON cache invalidation diverges.
+ *
+ * `revision`/`vary` join as-is (undefined → "undefined") to preserve the
+ * historical recipe byte-for-byte. Shared `hasher` is safe ONLY because this
+ * function is fully synchronous — no await between hash() and reset().
+ */
+export const computeRenderCb = (input: RenderCbInput): string => {
+ const cbString = [
+ input.revision,
+ input.vary,
+ createStableHref(input.href),
+ input.deploymentId,
+ ].join("|");
+ hasher.hash(cbString);
+ const cb = `${hasher.result()}`;
+ hasher.reset();
+ return cb;
+};
+
export type Options
= {
/** Section props partially applied */
props?: Partial
? K : P>;
@@ -119,15 +149,12 @@ export const useSection =
(
const hrefParam = href ?? request.url;
const stableHref = createStableHref(hrefParam);
- const cbString = [
- revisionId,
+ const cb = computeRenderCb({
+ revision: revisionId,
vary,
- stableHref,
- ctx?.deploymentId,
- ].join("|");
- hasher.hash(cbString);
- const cb = hasher.result();
- hasher.reset();
+ href: hrefParam,
+ deploymentId: ctx?.deploymentId,
+ });
const params = new URLSearchParams([
["props", JSON.stringify(props)],
diff --git a/mod.ts b/mod.ts
index a4c9bb4e6..cb801561a 100644
--- a/mod.ts
+++ b/mod.ts
@@ -45,6 +45,21 @@ export { type DecoRouteState } from "./runtime/middleware.ts";
export * from "./runtime/mod.ts";
export { Deco } from "./runtime/mod.ts";
export type { PageData } from "./runtime/mod.ts";
+export {
+ buildLazyUrl,
+ sectionModuleLookup,
+ serializeResolvedSection,
+} from "./runtime/routes/serialize-section.ts";
+export { computeRenderCb } from "./hooks/useSection.ts";
+export type { RenderCbInput } from "./hooks/useSection.ts";
+export type {
+ LazyUrlContext,
+ RenderJson,
+ ResolvedSection,
+ SectionJsonModule,
+ SerializeContext,
+ SerializedSection,
+} from "./runtime/routes/serialize-section.ts";
export * from "./types.ts";
export { allowCorsFor } from "./utils/http.ts";
export type { StreamProps } from "./utils/invoke.ts";
diff --git a/runtime/routes/render.tsx b/runtime/routes/render.tsx
index 094312721..a36279730 100644
--- a/runtime/routes/render.tsx
+++ b/runtime/routes/render.tsx
@@ -8,6 +8,12 @@ import { singleFlight } from "../../utils/mod.ts";
import { createHandler, DEBUG_QS } from "../middleware.ts";
import type { PageParams } from "../mod.ts";
import Render, { type PageData } from "./entrypoint.tsx";
+import {
+ type ResolvedSection,
+ sectionModuleLookup,
+ type SerializeContext,
+ serializeResolvedSection,
+} from "./serialize-section.ts";
interface Options {
resolveChain?: FieldResolver[];
@@ -91,6 +97,30 @@ export const handler = createHandler(async (
if (isDebugRequest) {
return Response.json({ debugData: state.vary.debug.build() });
}
+
+ if (opts.searchParams.get("format") === "json") {
+ const serializeCtx: SerializeContext = {
+ href: opts.href,
+ pathTemplate: opts.pathTemplate,
+ renderSalt: opts.renderSalt,
+ cb: opts.searchParams.get("__cb") ?? undefined,
+ getSectionModule: await sectionModuleLookup(),
+ };
+ const serialized = serializeResolvedSection(
+ page as ResolvedSection,
+ serializeCtx,
+ );
+ const jsonResponse = Response.json(serialized);
+ const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true;
+ jsonResponse.headers.set(
+ "cache-control",
+ shouldCache && shouldCacheFromVary
+ ? DECO_RENDER_CACHE_CONTROL
+ : "no-store, no-cache, must-revalidate",
+ );
+ return jsonResponse;
+ }
+
const props = {
params: ctx.req.param(),
url: ctx.var.url,
diff --git a/runtime/routes/serialize-section.ts b/runtime/routes/serialize-section.ts
new file mode 100644
index 000000000..d4c9d1ff8
--- /dev/null
+++ b/runtime/routes/serialize-section.ts
@@ -0,0 +1,202 @@
+import { Context } from "../../deco.ts";
+import { FieldResolver } from "../../engine/core/resolver.ts";
+
+const LAZY_SECTION_PATH = "/Rendering/Lazy.tsx";
+
+export interface ResolvedSection {
+ Component?: unknown;
+ props?: Record;
+ metadata?: {
+ component?: string;
+ resolveChain?: FieldResolver[];
+ };
+}
+
+export type SerializedSection =
+ | { component: string; props: Record }
+ | { component: string; lazyUrl: string };
+
+/**
+ * Module-level control of how a section renders to JSON.
+ *
+ * - `false` — the section has no JSON rendering; it is dropped from the
+ * serialized output entirely.
+ * - function — a projection applied to the resolved props before
+ * serialization. Annotate the parameter with `SectionProps`
+ * (or the section's own Props) to keep it compile-checked.
+ *
+ * ```ts
+ * export const renderJson = false;
+ *
+ * export const renderJson = (
+ * { internalOnly, ...rest }: SectionProps,
+ * ) => rest;
+ * ```
+ */
+export type RenderJson =
+ // deno-lint-ignore no-explicit-any
+ | ((props: any) => Record)
+ | false;
+
+export interface SectionJsonModule {
+ renderJson?: RenderJson;
+}
+
+export interface LazyUrlContext {
+ href: string;
+ pathTemplate: string;
+ renderSalt?: string;
+ cb?: string;
+}
+
+export interface SerializeContext extends LazyUrlContext {
+ /**
+ * Resolves a section module by its component name (resolveType) so the
+ * serializer can honor its `renderJson` export. When omitted, every
+ * section serializes with its full resolved props.
+ */
+ getSectionModule?: (component: string) => SectionJsonModule | undefined;
+}
+
+/**
+ * Builds a `getSectionModule` lookup over the active context's merged
+ * manifest (site + apps), keyed by resolveType.
+ */
+export const sectionModuleLookup = async (): Promise<
+ (component: string) => SectionJsonModule | undefined
+> => {
+ const runtime = await Context.active().runtime;
+ const sections = (runtime?.manifest as unknown as {
+ sections?: Record;
+ })?.sections ?? {};
+ return (component) => sections[component];
+};
+
+export function buildLazyUrl(
+ resolveChain: FieldResolver[],
+ ctx: LazyUrlContext,
+): string {
+ const params = new URLSearchParams([
+ ["format", "json"],
+ ["props", JSON.stringify({ loading: "eager" })],
+ ["href", ctx.href],
+ ["pathTemplate", ctx.pathTemplate],
+ [
+ "resolveChain",
+ JSON.stringify(FieldResolver.minify(resolveChain.slice(0, -1))),
+ ],
+ ]);
+ if (ctx.renderSalt) params.set("renderSalt", ctx.renderSalt);
+ if (ctx.cb) params.set("__cb", ctx.cb);
+ return `/deco/render?${params}`;
+}
+
+function isSectionShape(value: unknown): value is ResolvedSection {
+ if (!value || typeof value !== "object") return false;
+ const meta = (value as ResolvedSection).metadata;
+ return typeof meta?.component === "string";
+}
+
+function isLazyComponent(component: string | undefined): boolean {
+ return !!component?.endsWith(LAZY_SECTION_PATH);
+}
+
+function getInnerSection(node: ResolvedSection): ResolvedSection | undefined {
+ const inner = (node.props as { section?: unknown } | undefined)?.section;
+ return isSectionShape(inner) ? inner : undefined;
+}
+
+function getLoading(node: ResolvedSection): string | undefined {
+ return (node.props as { loading?: string } | undefined)?.loading;
+}
+
+function renderJsonOf(
+ ctx: SerializeContext,
+ component: string,
+): RenderJson | undefined {
+ return ctx.getSectionModule?.(component)?.renderJson;
+}
+
+/**
+ * Serializes a resolved section honoring its `renderJson` export.
+ * Returns `null` when the section opted out (`renderJson === false`) —
+ * callers must drop it from the output.
+ *
+ * A `renderJson` projection cannot run for lazy placeholders (their props
+ * are not resolved yet); it applies on the lazy fetch itself, when
+ * `/deco/render?format=json` serializes the resolved section.
+ */
+export function serializeResolvedSection(
+ node: ResolvedSection,
+ ctx: SerializeContext,
+): SerializedSection | null {
+ let current = node;
+ while (
+ isLazyComponent(current.metadata?.component) &&
+ getLoading(current) === "eager"
+ ) {
+ const inner = getInnerSection(current);
+ if (!inner) break;
+ current = inner;
+ }
+
+ if (
+ isLazyComponent(current.metadata?.component) &&
+ getLoading(current) === "lazy"
+ ) {
+ const inner = getInnerSection(current);
+ const component = inner?.metadata?.component ??
+ current.metadata!.component!;
+ if (renderJsonOf(ctx, component) === false) return null;
+ return {
+ component,
+ // The wrapper's resolveChain is what reconstructs this slot on the lazy
+ // fetch — it re-resolves the inner section and applies the inner's
+ // renderJson there. The inner is not independently resolvable, so its own
+ // chain would not rebuild this position; the wrapper's chain is correct.
+ lazyUrl: buildLazyUrl(current.metadata!.resolveChain ?? [], ctx),
+ };
+ }
+
+ const component = current.metadata!.component!;
+ const renderJson = renderJsonOf(ctx, component);
+ if (renderJson === false) return null;
+
+ const props = typeof renderJson === "function"
+ ? renderJson(current.props ?? {})
+ : current.props ?? {};
+
+ return {
+ component,
+ props: walkValue(props, ctx) as Record,
+ };
+}
+
+function walkValue(value: unknown, ctx: SerializeContext): unknown {
+ if (Array.isArray(value)) {
+ // Dropped sections (renderJson === false) are removed from arrays so
+ // consumers iterate a dense list; non-section nulls pass through.
+ const out: unknown[] = [];
+ for (const v of value) {
+ if (isSectionShape(v)) {
+ const serialized = serializeResolvedSection(v, ctx);
+ if (serialized !== null) out.push(serialized);
+ } else {
+ out.push(walkValue(v, ctx));
+ }
+ }
+ return out;
+ }
+ if (value && typeof value === "object") {
+ if (isSectionShape(value)) {
+ return serializeResolvedSection(value, ctx);
+ }
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value)) {
+ if (k === "Component" && typeof v === "function") continue;
+ out[k] = walkValue(v, ctx);
+ }
+ return out;
+ }
+ return value;
+}
diff --git a/utils/mod.ts b/utils/mod.ts
index c46c6627d..b30a9079a 100644
--- a/utils/mod.ts
+++ b/utils/mod.ts
@@ -12,7 +12,7 @@ export { adminUrlFor, isAdmin, resolvable } from "./admin.ts";
*/
export { readFromStream } from "./http.ts";
export { metabasePreview } from "./metabase.tsx";
-export { tryOrDefault } from "./object.ts";
+export { deepOmit, tryOrDefault } from "./object.ts";
export type { DotNestedKeys } from "./object.ts";
export { createServerTimings } from "./timings.ts";
export type { Device } from "./userAgent.ts";
diff --git a/utils/object.ts b/utils/object.ts
index 7d3b9db6f..dc71c7747 100644
--- a/utils/object.ts
+++ b/utils/object.ts
@@ -146,3 +146,59 @@ export const tryOrDefault = (fn: () => R, defaultValue: R): R => {
return defaultValue;
}
};
+
+const omitAtPath = (obj: unknown, parts: string[]): unknown => {
+ if (!obj || typeof obj !== "object" || parts.length === 0) return obj;
+
+ const [key, ...rest] = parts;
+
+ // `*` fans the remaining path out over every array element / record value.
+ if (key === "*") {
+ if (Array.isArray(obj)) {
+ return obj.map((v) => omitAtPath(v, rest));
+ }
+ return Object.fromEntries(
+ Object.entries(obj as Record).map(([k, v]) => [
+ k,
+ omitAtPath(v, rest),
+ ]),
+ );
+ }
+
+ // Preserve array shape: apply the same path to each element.
+ if (Array.isArray(obj)) {
+ return obj.map((v) => omitAtPath(v, parts));
+ }
+
+ const current = obj as Record;
+
+ if (rest.length === 0) {
+ const copy = { ...current };
+ delete copy[key];
+ return copy;
+ }
+
+ // Omit is a no-op when the path is absent — never create `undefined` branches.
+ if (!(key in current)) return current;
+
+ return {
+ ...current,
+ [key]: omitAtPath(current[key], rest),
+ };
+};
+
+/**
+ * Removes properties from `obj` by path, immutably.
+ *
+ * Supports top-level keys (`"seoProps"`), nested dot-notation paths
+ * (`"page.seo"`), and the `*` wildcard over record/array values
+ * (`"page.productsMap.*.internalFlag"`). Prefer typed rest-destructuring
+ * for top-level keys; reach for deepOmit on deep paths and dynamic keys.
+ */
+export const deepOmit = (obj: T, ...paths: string[]): T => {
+ let result: unknown = obj;
+ for (const path of paths) {
+ result = omitAtPath(result, path.split("."));
+ }
+ return result as T;
+};