⚠️ Alpha. Published to npm. APIs are unstable and will change.
A renderer SDK for Contentful's Experience Orchestration (ExO). You bring a design system; the SDK takes the Experience payload from the Experience Delivery API (XDA) and renders it.
Install the adapter for your framework:
npm install @contentful/experiences-react # React / Next.js
npm install @contentful/experiences-svelte # Svelte / SvelteKit
npm install @contentful/experiences-angular # AngularThat's the only SDK package you install. The adapter re-exports everything you need: resolver, types, renderer, design utilities, and the experience delivery client. The @contentful/experiences-sdk-core, @contentful/experiences-design, and @contentful/experiences-client packages are workspace-internal implementation details.
All three adapters share the same public-API shape: the same Config, the same fetchExperience, and the same styling model — design values are resolved on the server and auto-filled onto your components as ordinary props, which is the one recommended way to style them. The useDesignValues/getDesignValues/injectDesignValues accessor is an escape hatch for the cases props can't reach. The walkthrough below uses React. The Svelte / SvelteKit and Angular sections show the same three steps in each, with the differences called out inline, and runnable apps for all three live in examples/.
- Getting started
- Styling components
- Design tokens
- Advanced setup
- Error handling & troubleshooting
- Svelte / SvelteKit
- Angular
- Examples
- API reference
- Design system stays portable
- Workspace internals
- Contributing
- Support
- License
Three steps: register your components, fetch and resolve, render. The minimal page is one fetchExperience call whose result goes straight into one <ServerExperienceRenderer>.
// lib/experience-config.tsx
import {
defineComponent,
type Components,
type Config,
type ResolveToken,
type ExperienceTemplates,
} from '@contentful/experiences-react';
import { Button } from './components/Button';
import { Heading, type HeadingProps } from './components/Heading';
import { Page } from './components/Page';
const components: Components = {
// Keys match the segment after the last slash in `component.sys.urn`.
// Example URN: crn:contentful:::experience:spaces/$self/environments/$self/components/Button
//
// Register a bare component for the common case…
Button,
// …or the config object when you need defaults / resolveData.
Heading: defineComponent<HeadingProps>({
defaults: { text: 'Untitled' },
component: Heading,
}),
};
const experienceTemplates: ExperienceTemplates = {
// Optional. Keys match the last slash-segment of an `experienceTemplate` node's urn.
page: Page,
};
// Optional. Resolves opaque design-token ids to their underlying values (see "Design tokens").
const resolveToken: ResolveToken = (token) => `var(--${token.value.replaceAll('.', '-')})`;
export const experienceConfig: Config = { components, experienceTemplates, resolveToken };Components are registered by id and receive their content props together with their resolved design props (spacing, color, typography, layout) — the SDK resolves design server-side and auto-fills it onto the same props object. Styling from those props is the recommended path for every component; the useDesignValues() hook is an escape hatch for the cases props can't reach. Both are covered in Styling components below.
// app/[slug]/page.tsx (Next.js App Router)
import { fetchExperience, ServerExperienceRenderer } from '@contentful/experiences-react';
import { experienceConfig } from '@/lib/experience-config';
export default async function ExperiencePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const experience = await fetchExperience(
{ spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug },
{ accessToken: process.env.CDA_TOKEN! },
{ config: experienceConfig }
);
return <ServerExperienceRenderer experience={experience} config={experienceConfig} />;
}One fetchExperience call fetches the payload from the Experience Delivery API and resolves it: the renderer walks the payload, resolves design properties to plain scalars, runs any resolveData hooks in parallel, and dispatches each node to your registered React component.
It throws NotFoundError when the id doesn't exist. See Error handling in the API reference for routing that to your framework's 404.
The signature is three grouped params: what to fetch (space, env, experience), how to fetch (auth), and how to resolve (component config plus per-render metadata and a debug switch). Each group evolves on its own, so future personalization params, digital-property identifiers, and transport options fit their respective group without reshaping the signature.
You pass render context once, to fetchExperience. metadata, debug, and the viewport the design was pre-resolved against all ride along on the returned plan, so the renderer reads them from there — no second copy to keep in sync. The renderer accepts all three as props too, but only as overrides.
config is the one thing both calls need. It holds your component references, and those cannot travel on the plan: the plan is plain serializable data so it can cross a server/client boundary (React Server Components, SvelteKit's data, Angular's TransferState), and functions do not survive that trip. So config stays a prop, deliberately.
A working version is at examples/nextjs/app/[slug]/page.tsx.
There is one recommended way to style a component: read your design values off props. Design values are resolved on the server, inside fetchExperience / resolveExperience, and auto-filled onto your component's props alongside content. Each design property lands on a prop of the same key, so a design property named backgroundColor arrives as a prop named backgroundColor, already cascaded to the active viewport and with any design tokens resolved. Declare the design properties your component consumes, read them by name, and style from them:
// components/Heading.tsx
'use client';
import type { CSSProperties } from 'react';
interface HeadingProps {
text?: string;
// Resolved design values, auto-filled as props:
as?: 'h1' | 'h2' | 'h3'; // semantic key
fontSize?: string;
fontWeight?: string;
}
export function Heading({ text, as = 'h2', fontSize, fontWeight }: HeadingProps) {
const Tag = as; // semantic key, read by name
const style: CSSProperties = { fontSize, fontWeight };
return <Tag style={style}>{text}</Tag>;
}Since resolution happens on the server, the first SSR paint is already styled correctly, with no flash of unstyled content while the client works out the viewport. Both real CSS-shaped values (fontSize, backgroundColor) and author-defined semantic values (variant, as, ratio) arrive the same way. Read the semantic ones by name and pass the CSS-shaped ones into your style.
That one path covers everything the SDK supports:
- CSS-shaped values (
fontSize,backgroundColor) — pass straight intostyle. No conversion step, no casts. - Semantic values (
variant,as,ratio,target) — read by name and map them to whatever markup or CSS you want. A design property name doesn't have to match a CSS property name. - Design tokens —
resolveTokenturns the token id into a value before it reaches the prop, so your component never sees a token envelope. - Viewport changes — props re-arrive with the new viewport's values on resize; the component just re-renders from props.
Everything else in this section is a helper for a specific case, not an alternative default. If you're reaching for one, check that props genuinely don't cover what you need.
These plug in through resolveToken and props rather than through a separate styling path. resolveToken decides what a token id turns into, so return whatever your system consumes — a var(--…) reference, a value read out of your resolved Tailwind theme, an entry from a tokens package — and the component receives it as an ordinary prop value. From there, passing it into a style object, a styled interpolation, or a clsx call is your code's choice, and the SDK is indifferent to which. See the three worked resolveToken shapes under Design tokens.
The SDK does not generate a stylesheet or emit class names for your components. Class-based and CSS-in-JS systems are supported as consumers of resolved values, which is what resolveToken plus props gives you.
useDesignValues() reads the same resolved design record from context. It exists for two cases props can't cover:
- A nested presentational child that isn't a registered component. The SDK has no props to auto-fill onto it, so reading the nearest registered ancestor's design off context is the only way to reach it without threading props down by hand.
- Code that needs design outside the render path — a
useEffect, an imperative measurement.
Prefer props everywhere else. A registered component styling itself through the hook is doing extra work for no gain: it loses the typed prop surface and gains a context read.
'use client';
import { toCss, useDesignValues } from '@contentful/experiences-react';
// Not registered with the SDK, so it gets no auto-filled props of its own.
function Badge({ label }: { label: string }) {
const design = useDesignValues<{ color?: string }>(); // typed like useState<T>()
return <span style={{ color: design.color }}>{label}</span>;
}useDesignValues<T>()takes an optional type argument for editor ergonomics. It's an assertion rather than a runtime check, so treat keys as possiblyundefined.- It returns the same values that auto-fill props, and returns
{}outside a renderer, so components degrade gracefully in isolation. toCss(design, { include, exclude })converts a design record to aCSSPropertiesobject, keeping only keys that map to a real CSS property and dropping semantic ones. It accepts optional key filters. It's a companion to the hook — with props you read the keys you declared, so there's nothing to filter.- If
toCssdoesn't recognize a value, the property whitelist is extensible. Seepackages/design.
Svelte and Angular follow the same contract, with the same escape hatch under a different name: getDesignValues() and injectDesignValues(). See Svelte / SvelteKit and Angular.
When a design property's value is an ExO design token rather than a literal, XDA delivers it as { type: 'DesignToken', value: '<token-id>' }. The id is just an opaque reference. The actual value lives in your design system, and only you know how to look it up, so resolveToken is where you turn that reference into its underlying value before it reaches a component:
const resolveToken: ResolveToken = (token) => designTokens[token.value];
export const experienceConfig: Config = { components, resolveToken };The id shape is yours (dotted, slashed, flat, whatever your DTCG export emits). The SDK never interprets it; it passes you token.value and uses whatever you return. resolveToken looks the id up wherever you keep your tokens:
// 1. CSS custom properties. No JS cost, and the browser handles theme swaps.
resolveToken: (token) => `var(--${token.value.replaceAll('.', '-')})`;
// 2. A tokens object or DTCG package.
resolveToken: (token) => designTokens[token.value];
// 3. Tailwind. Walk the resolved theme by id path.
resolveToken: (token) => token.value.split('.').reduce((o, k) => o?.[k], tw.theme);Returning undefined means "not resolvable": the SDK drops that key so the component's own default takes over, and it won't appear in the auto-filled props (nor in useDesignValues()). With no resolveToken configured, tokens pass through unresolved and the SDK warns once in development, naming the component whose token it couldn't resolve.
When the simple path isn't enough, a few optional features cover most production needs. Per-page metadata flows into resolvers. debug mode surfaces what the SDK saw (visible missing-component boxes, verbose logs, a JSON dump panel). Viewport seeding makes SSR match the device. Async resolveData enriches props from external sources. Use any combination of them.
A full working route is at examples/nextjs/app/[slug]/page.tsx. Visit /<id>?debug=true&locale=en-US after running the example.
// app/[slug]/page.tsx
import { headers } from 'next/headers';
import { fetchExperience, ServerExperienceRenderer } from '@contentful/experiences-react';
import { detectViewportFromUserAgent } from '@/lib/detect-viewport';
import { experienceConfig } from '@/lib/experience-config';
export default async function ExperiencePage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug: experienceId } = await params;
const sp = await searchParams;
const previewMode = sp.preview === 'true';
const debug = sp.debug === 'true';
const locale = (sp.locale as string) ?? 'en-US';
// 1. UA → viewport id, so SSR matches the device's expected viewport.
const userAgent = (await headers()).get('user-agent') ?? '';
const initialViewportId = detectViewportFromUserAgent(userAgent);
// 2. Per-page metadata flows into every resolveData hook; debug is the
// single observability switch (logs + missing-component box + JSON dump).
// Note: `debug` (render/observability) is independent of `preview`
// (which token + host to fetch from).
const experience = await fetchExperience(
{ spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId, locale },
{
accessToken: process.env.CDA_TOKEN!,
previewToken: process.env.CPA_TOKEN,
preview: previewMode,
},
{
config: experienceConfig,
metadata: { slug: experienceId, locale },
debug,
}
);
return (
<ServerExperienceRenderer
experience={experience}
config={experienceConfig}
initialViewportId={initialViewportId}
metadata={{ slug: experienceId, locale }}
debug={debug}
/>
);
}Each defineComponent entry can declare an async resolveData that derives final props from external sources. The SDK fans these out across all nodes with Promise.all, so a slow resolver doesn't block its peers.
When does it run? Once per page render, inside resolveExperience(payload, config, opts?), before the renderer mounts. By the time <ServerExperienceRenderer> walks the tree, every resolveData has settled and its return value is sitting on node.props.resolved. Resolvers do not re-run on viewport changes, prop changes, or client-side navigation. To re-fetch, call resolveExperience again, typically on a fresh server request.
Button: defineComponent<ButtonProps>({
resolveData: async ({ content, experience }) => {
const { formattedLabel } = await fetchEnrichment(content.label as string);
return {
label: formattedLabel,
url: `/${experience.metadata.locale}/${experience.metadata.slug}`,
};
},
component: Button,
}),experience.metadata here is exactly what the page passed into resolveExperience's third argument. That's how per-page metadata reaches every resolver.
debug: true is the single observability switch, threaded end-to-end from fetchExperience through resolve and render. Turning it on:
- Logs the fetch (host, ids), the raw payload, the resolution steps, and per-node
resolveDatatimings under the[experiences:debug]prefix. - Shows the missing-component box —
MissingComponentrenders a visible box naming the unregisteredcomponentId(silentnullwhen debug is off). - Auto-mounts
<DebugExperience>above the tree — a collapsible panel dumping the resolved plan as pretty JSON, so you can see exactly what the SDK interpreted.
Wire it to any signal you like — a ?debug=true query param in development, a feature flag, process.env.NODE_ENV !== 'production'. It's independent of preview (which selects the delivery vs. preview token and host); debug is purely a render/observability concern.
const debug = sp.debug === 'true';
const experience = await fetchExperience(fetchOpts, clientOpts, {
config: experienceConfig,
metadata: { slug, locale },
debug, // logs + missing-component box + JSON dump panel
});
// The renderer takes the same top-level `debug` (auto-mounts <DebugExperience>):
<ServerExperienceRenderer experience={experience} config={experienceConfig} debug={debug} />;Read it inside a component with useExperience().debug (React) / getExperience().debug (Svelte) to add your own debug affordances.
The JSON-dump panel auto-mounts when debug is on, but you can also mount it manually anywhere — pass the resolved plan and, optionally, defaultOpen:
import { DebugExperience } from '@contentful/experiences-react';
<DebugExperience experience={experience} defaultOpen />;It renders a native <details> (collapses without extra JS) with a circular-reference-safe JSON serialization of the plan. v1 is the JSON dump; it has room to grow into a node-tree explorer and a resolveData timing panel.
When the payload references a component type that isn't in your Config, the renderer falls back to MissingComponent (a visible box when debug is on, silent null otherwise). Override it per-render with the renderUnknown prop on either renderer to ship your own fallback — a branded placeholder, an error boundary, a logging shim:
import type { MissingComponentProps } from '@contentful/experiences-react';
function Fallback({ componentId, nodeId }: MissingComponentProps) {
return (
<div data-unregistered={componentId}>
Unregistered component “{componentId}”{nodeId ? ` (#${nodeId})` : ''}.
</div>
);
}
<ServerExperienceRenderer
experience={experience}
config={experienceConfig}
renderUnknown={Fallback}
/>;renderUnknown receives { componentId, nodeId? }. It renders unconditionally (your override, not the SDK, decides whether to gate on debug via useExperience().debug). The Svelte adapter takes the same prop with a Svelte component.
Every non-happy-path — a malformed payload, a throwing resolveData, an unresolved design token, an unregistered id, and (the big one) a registered component that throws while rendering — degrades instead of crashing the page, and is reported as a plain Error you can inspect.
| When it's knowable | Where it lives | Failure modes | |
|---|---|---|---|
| Resolve-time | Inside resolveExperience(), before any renderer mounts |
plan.diagnostics (Error[], always present, [] on the happy path) |
malformed payload, malformed slot, unidentifiable node, resolveData failure, unresolved design token |
| Render-time | Per-framework, per-render | Merged into <DebugExperience>'s errors prop by the renderer |
unregistered component, unregistered experience template, a component that threw while rendering |
Each diagnostic is a plain new Error(message) — the message names the node/component/slot involved. When it wraps a real caught exception (a component that throws while rendering, or a resolveData that throws or rejects), .cause holds the original error, so the real stack trace stays reachable (console.error(err), or err.cause.stack).
| Failure | Behavior | Override point |
|---|---|---|
Malformed top-level payload (nodes/viewports not an array) |
Warn, treat as [], don't throw |
— |
Malformed slot shape (a hand-built PortableRenderPlan with a non-array slot) |
Warn, treat that slot as [] |
— |
A payload node with no readable component/experienceTemplate ref |
Warn, drop that node + its subtree, siblings unaffected | — |
resolveData throws (sync or async) or rejects |
Warn, node renders without props.resolved, other nodes' resolvers unaffected |
— |
A design token has no resolveToken mapping |
Warn, the raw DesignToken reaches the component instead of being dropped, in case it can make some use of it |
resolveToken on Config |
Component id not in Config.components |
Renders MissingComponent (visible box in debug, silent otherwise) |
renderUnknown |
Experience Template id not in Config.experienceTemplates |
Warn, renders the template's slot children unwrapped (content survives) | — |
| A registered component throws while rendering | Isolated per-node; siblings render normally. Renders ComponentError (visible box in debug, silent otherwise) |
renderError |
Sibling of renderUnknown, same shape, different failure mode:
import type { ComponentErrorProps } from '@contentful/experiences-react';
function Fallback({ componentId, nodeId, message }: ComponentErrorProps) {
return (
<div data-render-error={componentId}>
“{componentId}”{nodeId ? ` (#${nodeId})` : ''} failed to render{message ? `: ${message}` : ''}
.
</div>
);
}
<ServerExperienceRenderer
experience={experience}
config={experienceConfig}
renderError={Fallback}
/>;The Svelte and Angular adapters take the same prop with their own component shape (ComponentErrorProps / cf-component-error's inputs are componentId, nodeId?, message?).
When there's anything to report, <DebugExperience> renders a plain list of each diagnostic's message above the JSON dump, so errors are visible in the debug surface, not console-only.
Pass errors explicitly if you're mounting <DebugExperience> yourself instead of relying on the renderer's auto-mount:
<DebugExperience experience={plan} errors={[...plan.diagnostics, ...myRenderTimeDiagnostics]} />Unlike every other diagnostic, component-render-error can only be discovered by actually rendering the component — and each framework's server renderer handles that failure differently:
| Framework | SSR behavior | CSR behavior | Diagnostic recorded during SSR? |
|---|---|---|---|
| React | Degrades gracefully — an internal <Suspense> boundary emits the renderError fallback under both the legacy and streaming renderers (streaming defers the real retry to client hydration). |
Catches via the standard class error-boundary mechanism. | No — only fires once the client executes. |
| Svelte | No recovery — <svelte:boundary> doesn't run its catching machinery server-side; a throw during the server walk fails the entire render. |
Catches normally. | No — SSR never reaches the catching code. |
| Angular | Catches identically to CSR — there's no separate server renderer, so SSR and CSR run through the exact same component-creation path. | Same code path as SSR. | Yes, for a creation-time throw. |
Practical implications:
- React (Next.js App Router, Remix streaming, or any Suspense-aware pipeline): a throwing component degrades gracefully server-side, but the diagnostic itself only shows up after hydration — check
<DebugExperience>again on the client if you need it during SSR. - Svelte/SvelteKit: a throwing component fails the whole page server-side. Add a
handleErrorhook insrc/hooks.server.tsto turn that into a controlled error response instead of a raw crash — it won't give you per-node isolation, but it's the difference between a blank 500 and one you control. Test your components' unhappy paths before shipping; don't rely on SSR to catch a bad one for you. - Angular gives the strongest guarantee here: a component that throws during creation is isolated identically whether you're rendering server- or client-side.
- None of the three frameworks catch a throw during a later re-render with full parity — see below.
- React / Svelte: caught the same way as a creation-time throw — no special handling needed.
- Angular: partially caught. A later throw from the adapter's own resolution step (e.g. a
resolveTokenthat starts failing) swaps that node to the error fallback and recovers automatically once resolution succeeds again. A throw from inside the customer component's own internals on a later change-detection pass (its own template expression, computed, or lifecycle hook) is not caught today — nothing in this adapter sits on that code path.
@contentful/experiences-svelte is the Svelte 5 adapter. The public API matches React one for one: the same Config, fetchExperience, resolveExperience, ServerExperienceRenderer/ClientExperienceRenderer, design tokens, and defineComponent/defineExperienceTemplate. Three differences, all mechanical:
| Concern | React | Svelte |
|---|---|---|
| Register a component | component: takes a React component |
component: takes a Svelte component |
| Read design (escape hatch) | useDesignValues() |
getDesignValues() (read inside a $derived) |
| Runtime context | useExperience() / useContentfulComponent() |
getExperience() / getContentfulComponent() |
| Slots | each slot is a named React-node prop | default slot is a children Snippet; others via getContentfulComponent().slots + <NodesRenderer /> |
// lib/experience-config.ts
import {
defineComponent,
type Components,
type Config,
type ResolveToken,
} from '@contentful/experiences-svelte';
import Button from './components/Button.svelte';
import Heading, { type HeadingProps } from './components/Heading.svelte';
const components: Components = {
Button, // bare component…
Heading: defineComponent<HeadingProps>({ defaults: { text: 'Untitled' }, component: Heading }),
};
const resolveToken: ResolveToken = (token) => designTokens[token.value];
export const experienceConfig: Config = { components, resolveToken };Resolved design auto-fills props here too, so a component styles itself straight from $props():
<!-- components/Heading.svelte -->
<script lang="ts">
export interface HeadingProps {
text?: string;
as?: 'h1' | 'h2' | 'h3'; // resolved design, auto-filled as props
fontSize?: string;
fontWeight?: string;
}
let { text, as = 'h2', fontSize, fontWeight }: HeadingProps = $props();
</script>
<svelte:element this={as} style="font-size: {fontSize}; font-weight: {fontWeight};">{text}</svelte:element>getDesignValues() is the escape hatch, for the same two cases as React — a nested child that isn't a registered component, or design needed outside the render path. Read it inside a $derived so it stays reactive across viewport changes:
<!-- escape hatch: a nested child that isn't registered, so it has no props of its own -->
<script lang="ts">
import { getDesignValues } from '@contentful/experiences-svelte';
let { label }: { label: string } = $props();
const design = $derived(getDesignValues<{ color?: string }>());
</script>
<span style="color: {design.color};">{label}</span>// routes/[slug]/+page.server.ts
import { fetchExperience } from '@contentful/experiences-svelte';
import { CDA_TOKEN, SPACE_ID } from '$env/static/private';
import { experienceConfig } from '$lib/experience-config';
export const load = async ({ params }) => {
const experience = await fetchExperience(
{ spaceId: SPACE_ID, environmentId: 'master', experienceId: params.slug },
{ accessToken: CDA_TOKEN },
{ config: experienceConfig }
);
return { experience };
};<!-- routes/[slug]/+page.svelte -->
<script lang="ts">
import { ServerExperienceRenderer } from '@contentful/experiences-svelte';
import { experienceConfig } from '$lib/experience-config';
let { data } = $props();
</script>
<ServerExperienceRenderer experience={data.experience} config={experienceConfig} />Everything else applies identically: advanced setup (preview, viewport seeding, async resolveData), the API reference below, merge precedence, and design tokens. Substitute the Svelte spelling of each hook.
@contentful/experiences-angular is the Angular adapter (peer range ^20 || ^21 || ^22). Same Config, same fetchExperience, same defineComponent/defineExperienceTemplate, same design tokens. The differences follow from Angular having no prop spread and no lazy renderable-children primitive:
| Concern | React | Angular |
|---|---|---|
| Register a component | component: takes a React component |
component: takes a standalone component class (Type<unknown>) |
| Read design (escape hatch) | useDesignValues() |
injectDesignValues() — returns a Signal, read it inside a computed() |
| Runtime context | useExperience() / useContentfulComponent() |
injectExperience() / injectContentfulComponent() |
| Renderers | <ServerExperienceRenderer /> |
<cf-server-experience> / <cf-experience> |
| Slots | each slot is a named React-node prop | each slot is an @Input() holding PortableRenderNode[], rendered with the exported *cfNodes |
Two Angular-only consequences worth knowing up front:
- Only declared inputs are set. The adapter filters merged props to the target component's declared inputs, because binding an input a component doesn't declare is an error. Keys a component doesn't declare are dropped rather than passed — they stay reachable through
injectDesignValues(). *cfNodesis load-bearing, not an escape hatch. It is how you render a slot, becauseprojectableNodesis positional and eager, which would break slot laziness. It is a structural directive, so it adds no element between your markup and the slot children.
// lib/experience-config.ts
import {
defineComponent,
type Components,
type Config,
type ResolveToken,
} from '@contentful/experiences-angular';
import { ButtonComponent } from './components/button.component';
import { HeadingComponent, type HeadingProps } from './components/heading.component';
const components: Components = {
Button: ButtonComponent, // bare class…
Heading: defineComponent<HeadingProps>({
defaults: { text: 'Untitled' },
component: HeadingComponent,
}),
};
const resolveToken: ResolveToken = (token) => designTokens[token.value];
export const experienceConfig: Config = { components, resolveToken };Resolved design auto-fills declared inputs here too, so the recommended path is the same: declare the design properties you consume as inputs and read them by name.
In Angular, declaring the input is also what makes the design property arrive at all — the renderer binds only inputs a component declares, because binding an undeclared input is an error. Undeclared keys stay reachable through injectDesignValues().
// components/heading.component.ts
import { NgStyle } from '@angular/common';
import { Component, Input, computed, signal } from '@angular/core';
@Component({
selector: 'app-heading',
imports: [NgStyle],
template: `<h2 [ngStyle]="style()">{{ textValue() }}</h2>`,
})
export class HeadingComponent {
protected readonly textValue = signal<string | undefined>(undefined);
protected readonly fontSizeValue = signal<string | undefined>(undefined);
protected readonly fontWeightValue = signal<string | undefined>(undefined);
// Setter takes the API name; the signal takes a distinct `…Value` name.
@Input() set text(value: string | undefined) {
this.textValue.set(value);
}
/** Design property. */
@Input() set fontSize(value: string | undefined) {
this.fontSizeValue.set(value);
}
/** Design property. */
@Input() set fontWeight(value: string | undefined) {
this.fontWeightValue.set(value);
}
protected readonly style = computed(() => ({
fontSize: this.fontSizeValue(),
fontWeight: this.fontWeightValue(),
}));
}injectDesignValues() is the escape hatch. It returns a Signal, so read it inside a computed(), and like every inject()-based API it must be called from an injection context.
Signal inputs (input()) are AOT-only, so the adapter's own components and these examples use decorator @Input() with a setter bridging into a signal. Your app is free to use input() if it always builds AOT.
Fetch on the server — in an Express handler rather than an Angular route resolver, since resolvers also run in the browser during client-side navigation and would ship your CDA token to the client:
// server.ts
import { fetchExperience } from '@contentful/experiences-angular';
import { experienceConfig } from './app/lib/experience-config';
const experience = await fetchExperience(
{ spaceId, environmentId: 'master', experienceId: slug },
{ accessToken: CDA_TOKEN },
{ config: experienceConfig }
);
// Hand it to the app as the requestContext argument of AngularNodeAppEngine.handle().
const response = await angularApp.handle(req, { experience });// pages/experience-page.component.ts
@Component({
imports: [ServerExperienceRendererComponent],
template: `<cf-server-experience [experience]="experience" [config]="config" />`,
})
export class ExperiencePageComponent {
protected readonly experience = inject(ExperienceStore).data?.experience ?? null;
protected readonly config = experienceConfig;
}PortableRenderPlan is plain JSON — component classes live in experienceConfig, never in the plan — so relaying it to the browser through TransferState for hydration works without special handling. See examples/angular for the full wiring and packages/adapter-angular/README.md for the complete API surface and parity table.
Runnable apps for all three frameworks live in examples/. They register the same components against the same Experience payload, so they render identically; only the framework-specific setup differs.
| Example | Stack | Shows |
|---|---|---|
examples/nextjs |
Next.js 15 (App Router) | Preview mode, UA→viewport, async resolveData, design tokens, styling hooks |
examples/sveltekit |
SvelteKit 2 + Svelte 5 | 1:1 parity with the Next.js app; hydration-safe viewport seeding via +page.server.ts |
examples/angular |
Angular 20 + @angular/ssr |
Same, on zoneless Angular; fetch in Express (keeps tokens server-side) + TransferState relay |
Both examples render the same demo Experience. To run them you first seed that Experience into your Contentful space with the one-time bootstrap script — the script uses the experiences management API to provision the ContentType, entries, assets, design tokens, Components, Experience Template, DataAssemblies, and the Experience itself.
npm install --ignore-scripts
npm run build # build the SDK packages
# 1. Seed the demo Experience into your Contentful space (one-time).
cd examples/scripts
cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN
npm run bootstrap # prints the experienceId at the end (default: `landing`)
# 2. Run one of the example apps against the seeded space.
cd ../nextjs # or ../sveltekit
cp .env.example .env.local # sveltekit uses .env; fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN
npm run devThen visit /landing (or whichever experienceId the bootstrap printed). See each example's README for its file map and route-by-route walkthrough.
Tokens. CMA_TOKEN is a Personal Access Token that only the bootstrap script sees. CDA_TOKEN is a Content Delivery API token — this is what the running app uses at runtime. CPA_TOKEN is a Content Preview API token, only needed if you want to exercise ?preview=true; see each example's README for details.
Async. Fetches an Experience from the Experience Delivery API and resolves it in one call, the same as fetching the payload yourself and then calling resolveExperience. Returns a PortableRenderPlan.
Three positional args map to three concerns that evolve independently:
| Arg | Type | Purpose |
|---|---|---|
experienceOptions |
{ spaceId, environmentId, experienceId, locale?, withSourceMap? } |
Which Experience to fetch, and what to fetch alongside it. Future digital-property identifiers widen this type. |
clientOptions |
{ accessToken, previewToken?, preview?, host? } or { client } |
How to fetch. Discriminated union: pass credentials inline (with optional preview toggle) or pass in your own ContentfulViewDeliveryClient. |
resolveOptions |
{ config, metadata?, debug?, initialViewportId? } |
How to resolve. metadata flows into every resolveData hook as ctx.experience.metadata; debug turns on logging + the visible missing-component box. |
All three of metadata, debug, and initialViewportId are recorded on the returned plan, so the renderer picks them up without being passed them again.
Configure both tokens up front and flip preview: true per call to hit the preview API. preview: true without previewToken throws an error. host is a full base-URL string for custom endpoints (staging, proxy, per-region); when set, it wins over the preview-derived default host.
// Inline credentials, client created internally
const plan = await fetchExperience(
{ spaceId: '...', environmentId: 'master', experienceId: slug, locale: 'en-US' },
{
accessToken: process.env.CDA_TOKEN!,
previewToken: process.env.CPA_TOKEN!,
preview: true, // flip per request; omit or set false for delivery
},
{ config: experienceConfig, metadata: { slug }, debug: true }
);
// Pre-created client, useful when you manage the client lifecycle yourself
import { createClient } from '@contentful/experiences-react';
const client = createClient({ accessToken: process.env.CDA_TOKEN! });
const plan = await fetchExperience(
{ spaceId, environmentId, experienceId },
{ client },
{ config: experienceConfig }
);Error handling. The underlying delivery client throws NotFoundError (re-exported from the adapter) when the Experience ID doesn't exist, plus UnauthorizedError, ForbiddenError, and so on for other 4xx/5xx responses. An Experience with no published nodes is not a 404; it resolves to a valid PortableRenderPlan with nodes: [] (draft, unpublished, or empty-locale content). Route the missing-experience case to your framework's 404 idiom:
try {
const experience = await fetchExperience(/* … */);
return <ServerExperienceRenderer experience={experience} config={config} />;
} catch (err) {
if (err instanceof NotFoundError) notFound();
throw err;
}Set withSourceMap: true in experienceOptions to fetch the content source map alongside the experience. It lands on plan.sourceMap:
const plan = await fetchExperience(
{ spaceId, environmentId, experienceId: slug, withSourceMap: true },
{ accessToken: process.env.CDA_TOKEN! },
{ config: experienceConfig }
);
plan.sourceMap; // ExperienceSourceMap | undefinedThat is the whole opt-in. Previously this meant hand-building an extensions: { sourceMap: {} } request body, calling the right client method yourself, digging the map back out of extensions on the response, and re-plumbing it into resolveExperience.
Two things to know before switching it on:
- The map is large. It carries per-node field provenance for every entry, asset, and layer in the experience. That is why it is opt-in rather than always fetched, and why
plan.sourceMapisundefinedby default. - The request becomes a
POST. The source-map opt-in is a request-body field, and the delivery API only accepts a body on itsgetWithOverridesoperation. Everything else — query parameters, tokens, headers, and the response shape — is identical, but aPOSTis not CDN-cacheable the way the plainGETis.
ExperienceSourceMap types the scalar fields and leaves the collections as unknown[]: the SDK passes the map through without interpreting it, and the core package carries no dependency on the delivery client. When you need the full nested shape, narrow it:
import type { ContentfulViewDelivery } from '@contentful/experiences-react';
const map = plan.sourceMap as ContentfulViewDelivery.HydratedExperienceViewExtensionsSourceMap;Only needed if you drive the delivery client directly instead of going through fetchExperience.
The experience endpoint types its response as a union of the legacy and renamed entity shapes (HydratedView | HydratedExperienceView). In practice the alpha-feature header guarantees the renamed one, but TypeScript cannot know that, so a direct caller is left writing the cast by hand. toExperiencePayload does that narrowing once:
import {
createClient,
readSourceMap,
resolveExperience,
toExperiencePayload,
} from '@contentful/experiences-react';
const client = createClient({ accessToken: process.env.CDA_TOKEN! });
const response = await client.experience.get(spaceId, environmentId, slug, { locale: 'en-US' });
const plan = await resolveExperience(toExperiencePayload(response), experienceConfig, {
sourceMap: readSourceMap(response),
});Both are type-level only — no validation, no copying, no runtime cost. readSourceMap returns undefined when the response carries no map, which is what resolveExperience expects for "no source map".
Functional constructor over ContentfulViewDeliveryClient for the SDK's option shape. It maps accessToken to token and host to baseUrl, and passes everything else through. Prefer this over new ContentfulViewDeliveryClient({...}) so field names stay consistent with fetchExperience's inline-credentials path.
createClient is a one-time setup primitive: it builds a single client bound to a single token, and is best for cases where you're managing the client lifecycle yourself (custom caching, request middleware, sharing the instance across code paths). It does not participate in the per-request preview toggle.
If you need runtime-dynamic swaps between delivery and preview use
fetchExperience's inline-credentials path. Pass{ accessToken, previewToken, preview }directly instead of pre-building a client and passing{ client }. Thepreviewboolean is a per-call selector; a pre-built client is bound to whichever token it was constructed with and can't swap.
import { createClient } from '@contentful/experiences-react';
const client = createClient({
accessToken: process.env.CDA_TOKEN!,
host: 'https://preview-staging.example.com', // optional custom base URL
// headers, timeoutInSeconds, maxRetries, fetch, logging, etc. all pass through
});Every delivery request carries an alpha-feature header — see The alpha-feature header.
The Experience Delivery API gates the entity shapes this SDK reads behind a header:
x-contentful-enable-alpha-feature: new-exo-entity-types
A payload fetched without it has a different shape that the SDK will not resolve. You never send it yourself. @contentful/experience-delivery sets it on every request as of 1.0.0-dev.7, so every path is covered: fetchExperience, a client from createClient, and a raw ContentfulViewDeliveryClient you construct and call directly.
That means driving the raw client and resolving the payload yourself needs no header plumbing:
import { ContentfulViewDeliveryClient, resolveExperience } from '@contentful/experiences-react';
const client = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! });
const payload = await client.experience.get(spaceId, environmentId, experienceId, { locale });
const plan = await resolveExperience(payload, experienceConfig);To send a different alpha-feature set, pass it in the per-request headers — the delivery client re-applies its own default after client-level headers, so a createClient({ headers }) entry for this key does not take effect:
await client.experience.get(
spaceId,
environmentId,
experienceId,
{ locale },
{ headers: { 'x-contentful-enable-alpha-feature': 'some-other-set' } }
);Async. Walks the payload, classifies properties, runs every component's resolveData in parallel, and returns a PortableRenderPlan ready to hand to a renderer.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
payload |
ExperiencePayload, an XDA response (or any structurally-compatible object) |
yes | n/a | The Experience payload to resolve. |
config |
Config, { components, experienceTemplates? } from defineComponent / defineExperienceTemplate |
yes | n/a | Your component + experience-template registry. |
opts |
{ metadata?; debug?; initialViewportId?; sourceMap? } |
no | {} |
metadata (default {}) is exposed to every resolveData as ctx.experience.metadata. debug (default false) logs the resolution steps and per-node resolveData timings, and threads through as ctx.experience.debug. initialViewportId picks the viewport design is pre-resolved against. sourceMap is carried onto the plan as-is. |
metadata, debug, and the resolved fallback viewport index are all written onto the returned plan, which is what lets the renderer read them instead of taking them as props.
SSR-friendly renderer. No reactive subscriptions; the active viewport is resolved once. Safe to use in React Server Components.
Only experience and config are needed. The other three are overrides — every one of them has a value on the plan already, so pass them only to render differently than the plan was fetched for.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
experience |
PortableRenderPlan |
yes | n/a | The resolved plan from fetchExperience or resolveExperience. An empty-nodes plan renders nothing. |
config |
Config |
yes | n/a | Same registry passed to resolveExperience. Looked up at render time for dispatch. Cannot travel on the plan — see below. |
initialViewportId |
string |
no | The plan's fallback viewport | Seeds the active viewport. Defaults to the viewport design was pre-resolved against, so first paint needs no recompute. |
metadata |
Record<string, unknown> |
no | The plan's metadata |
Shallow-merges over plan.metadata. Read via useExperience().metadata. |
debug |
boolean |
no | The plan's debug |
Observability switch. Shows the missing-component box, and auto-mounts <DebugExperience> above the tree. An explicit false overrides a plan fetched with debug on. |
renderUnknown |
(props: MissingComponentProps) => ReactNode |
no | MissingComponent |
Fallback for unregistered component types. Default MissingComponent: visible box when debug is on, silent null otherwise. |
Why config is still a prop. The plan is plain serializable data by design — that is what lets it cross the RSC boundary, SvelteKit's data, and Angular's TransferState. config holds component references and an optional resolveToken function, neither of which survives serialization. So it is passed to both calls on purpose, and it is the only thing that is.
Client-side renderer with reactive viewport tracking via window.matchMedia. Same prop shape as ServerExperienceRenderer, including the plan-carried defaults.
Server-safe: it does not throw during SSR. First paint uses the seeded viewport and registers no listeners when there is no window, so server output matches <ServerExperienceRenderer>; matchMedia takes over after hydration.
Identity helper that narrows resolveData and component parameter types to your declared Props. A registry entry can also be a bare component (Button instead of { component: Button }) when it needs no defaults or resolveData.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
defaults |
Partial<Props> |
no | {} |
Lowest-precedence props. Merged in before content / resolveData / slots. |
resolveData |
(ctx: ResolveContext) => Partial<Props> | Promise<Partial<Props>> |
no | n/a | Sync or async transform. Runs once per page during resolveExperience (before render); does not re-run on viewport changes. Receives { content, design (unresolved), experience }. |
component |
ComponentType<Props> |
yes | n/a | The React component. Receives the merged props (content + resolved design + resolveData). Design is also readable via useDesignValues(); runtime context and raw payload via useExperience() / useContentfulComponent(). |
Same shape as defineComponent. A coded Experience Template is an ordinary node in the experience — the only difference is which registry its id resolves against (experienceTemplates rather than components). Its slots arrive as named props like any component's, so a template with a content slot receives a content: ReactNode[] prop and renders the page layout around it.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
defaults |
Partial<Props> |
no | {} |
Same as components. |
resolveData |
(ctx: ResolveContext) => Partial<Props> | Promise<Partial<Props>> |
no | n/a | Same as components. Runs once per page against the template node's props. |
component |
ComponentType<Props> |
yes | n/a | Same as components: the merged props (content + resolved design + resolveData + named slot props). Design is also readable via the same hooks. |
useDesignValues() returns the current node's resolved design record (viewport-cascaded and token-resolved). The optional type argument shapes the record as an assertion, not a runtime check. toCss() converts that record to a CSSProperties object, keeping only keys that map to a real CSS property and dropping semantic ones. See Styling components.
ToCssOptions: { include?: string[]; exclude?: string[] }, key filters applied against the original record keys.
Read the runtime context and raw Contentful payload from inside a component. Call them at the top of your component body; nothing is injected as props. useExperience() returns the RenderContext (below). useContentfulComponent() and useContentfulExperienceTemplate() return the raw payload (below) or null outside a node / experience template.
React hook used internally by ClientExperienceRenderer. You'll rarely need it directly. Returns { activeViewportIndex } and updates on matchMedia changes.
Default renderUnknown fallback. Visible box naming the unregistered componentId when useExperience().debug === true, silent null otherwise (a console.warn fires in both cases). Override per-render via the renderUnknown prop on either renderer — see Custom fallback for unregistered components.
First-party debug panel. Renders the resolved PortableRenderPlan as pretty, circular-safe JSON inside a collapsible native <details>. Auto-mounted by the renderers when debug is on, or mount it manually anywhere. defaultOpen (default false) expands it on first paint. Import DebugExperienceProps for the prop type.
Every component (via useExperience()) and resolveData hook (via ctx.experience) sees an experience context. The shape:
| Field | Type | Available in | Description |
|---|---|---|---|
debug |
boolean |
render, resolveData | The single observability switch (replaces the old isPreview). Drives verbose logging, the visible missing-component box, and the auto-mounted <DebugExperience> panel. Components can branch on it for their own debug affordances. |
metadata |
Record<string, unknown> |
render, resolveData | Whatever the page passed in via resolveExperience / fetchExperience opts, carried on the plan. The renderer's metadata prop shallow-merges over it. Free-form and untyped on purpose — see below. |
viewports |
ViewportDef[] |
render, resolveData | All viewports declared on the experience, in cascade order. Stable for the duration of the page render. |
activeViewport |
ViewportDef |
render only | The currently active viewport, the last-matching media query or device trait. Absent in resolveData because it's a render-time value that would re-fire async resolvers on every viewport change. |
activeViewportIndex |
number |
render only | Index of activeViewport in viewports. Same caveat. |
fallbackViewportIndex |
number |
render only | Index the design was pre-resolved against server-side. Equal to activeViewportIndex unless you rendered a different viewport than you fetched for, in which case the renderer recomputed design from the raw per-viewport values. |
RenderContext is declared once in the SDK core and re-exported by every adapter, so all three expose exactly the same shape — useExperience() in React, getExperience() in Svelte, injectExperience() in Angular.
metadata is the one place to pass your application's own data through to components and resolvers — request context, locale, feature flags, a session id. The SDK never writes a key into it; you get back exactly what you put in.
It is typed Record<string, unknown> deliberately. There is no separate typed slot beside it: a second customer-owned channel would duplicate this one with no clear rule for which to reach for. Narrow it at the read site when you want types:
interface PageMeta {
slug: string;
locale: string;
}
const { slug, locale } = useExperience().metadata as PageMeta;Contents must be plain serializable data. metadata rides on the plan, which crosses server/client boundaries — functions, class instances, and client handles will not survive. Pass an id and rebuild the handle on the other side.
useContentfulComponent() returns the unprocessed Contentful-side input for the enclosing node: unresolved design values, the originating componentId, the nodeId if the editor supplied one, and the resolveData output. (useContentfulExperienceTemplate() is the experience-template equivalent.)
Use it for:
- Custom design resolution outside the SDK's default cascade, such as emitting CSS variables or multi-brand theming.
- Branching by
componentIdin a generic wrapper component. - Analytics / instrumentation keyed off
nodeId. - Debug overlays when
useExperience().debugis on (a<details>with the raw payload) — or reach for the built-in<DebugExperience>.
Components see ContentfulComponent:
| Field | Type | Description |
|---|---|---|
componentId |
string |
The id from component.sys.urn's last slash-segment. |
nodeId |
string | undefined |
Pass-through of node.id from the payload when supplied; undefined otherwise. |
content |
Record<string, unknown> |
Editorial values exactly as the payload delivered them. |
design |
Record<string, DesignPropValue> |
Design properties in their raw form (not viewport-resolved). |
resolved |
Record<string, unknown> | undefined |
Return value of the component's resolveData hook. undefined when no hook is registered. |
Experience Templates see ContentfulExperienceTemplate, the same shape but with experienceTemplateId instead of componentId — a coded Experience Template is an ordinary node, so it carries a nodeId too.
The component receives a flat set of props composed in this order:
defaults, fallback values fromdefineComponent- resolved
design, viewport-cascaded and token-resolved, keyed by the raw design-property name contentProperties, editorial values from the payloadresolveData()output, your transform's return value- slot props, each named slot becomes a pre-rendered React subtree
So if content.text === 'Hello' and defaults.text === 'Default', your component receives text: 'Hello'. Design sits below content and resolveData, so an explicit editorial or resolver value always wins over design when keys collide.
The same resolved design values are also published on context, which is what the useDesignValues() escape hatch reads. Runtime context and the raw (pre-resolution) payload come through useExperience() and useContentfulComponent(), which are never injected as props.
Any same-named key coming from contentProperties (from XDA) takes precedence over a defaults value defined in defineComponent / defineExperienceTemplate. Only a field genuinely absent from contentProperties falls through to the defaults defined in defineComponent / defineExperienceTemplate.
The integration boundary is the registry, not the components. Your Button is plain React with its own ButtonProps — content and resolved design values are just props on that interface, so the component imports nothing SDK-shaped and works in Storybook, in unit tests, and in unrelated apps. Rendering it anywhere is a matter of passing props. A component that prefers the hook imports only useDesignValues(), which returns {} outside a renderer so it still degrades gracefully.
// components/Button.tsx: plain React, zero SDK coupling
export interface ButtonProps {
text?: string;
url?: string;
type?: 'primary' | 'secondary';
}
export function Button({ text, url, type = 'primary' }: ButtonProps) {
/* … */
}The SDK-specific wiring (defaults, resolvers, prop reshaping, slot binding) all lives in one file, lib/experience-config.tsx, so it's easy to scan and easy to change.
This is an Nx monorepo. You install only the framework adapter; the rest is workspace-internal.
| Folder | npm name | Scope |
|---|---|---|
packages/core |
@contentful/experiences-sdk-core |
Internal. Runtime-neutral types + resolveExperience. |
packages/design |
@contentful/experiences-design |
Internal. Viewport math (getValueForViewport, resolveDesignProperties, toCssMediaQuery). |
packages/client |
@contentful/experiences-client |
Internal. Experience delivery client + fetchExperience. |
packages/adapter-react |
@contentful/experiences-react |
Public. React renderer + re-exports of everything else. |
packages/adapter-svelte |
@contentful/experiences-svelte |
Public. Svelte 5 renderer with the same public API shape. |
packages/adapter-angular |
@contentful/experiences-angular |
Public. Angular renderer (^20 || ^21 || ^22) with the same public API shape. |
Future framework adapters slot in under the same pattern (packages/adapter-vue, and so on) and consume the same internal core and design packages.
npm install --ignore-scripts # husky prepare can fail in fresh clones; safe to skip
npm run build # nx run-many -t build (topological)
npm test # nx run-many -t test
npm run lint # nx run-many -t lint
npm run typecheck # nx run-many -t typecheck
npx nx graph # visual dep graph
npm run release:dry # rehearse independent releaseFor deeper context (design decisions, multi-framework architecture notes, conventions, and gotchas), see AGENTS.md.
Contributions are welcome. See CONTRIBUTING.md for development setup, coding standards, and the PR process, along with our Code of Conduct.
For usage questions and support, visit the Contentful support center. Use GitHub issues for bugs and feature requests.