Skip to navigation

Render experiences in Astro

There is no official Astro adapter. This guide builds a custom renderer on the React adapter, @contentful/experiences-react. For a complete app, see the Astro example.

Astro renders every page to static HTML and ships no JavaScript by default. A component becomes interactive only when it’s used in an .astro file with a client:* directive. Astro calls each of these components an island.

ServerExperienceRenderer renders an experience as a single React tree, so Astro sees one component. The components inside it render as HTML but never hydrate, so an accordion shows up but doesn’t toggle. ClientExperienceRenderer makes them interactive by hydrating the whole experience, which ships every component’s JavaScript to the browser.

With the custom renderer, your Astro page walks the experience tree, renders each component as static HTML by default, and hydrates only the components you choose as islands.

How it works

The @contentful/experiences-react/custom-renderer entry point exports the building blocks the SDK renderers are made of:

ExportDescription
resolveNodePropsLooks up the component registered for a node and returns its final props. Returns status: 'unregistered' for a node with no registered component.
ExperienceNodeRenders one resolved node the way the SDK renderers do, including the context that SDK hooks read.
createExperienceNodeReturns an ExperienceNode bound to a set of components, for rendering a node inside an island.

Astro serializes island props into the page, so an island can’t receive a component reference. Pass the node’s snapshot instead. The snapshot is a plain-data copy of the resolved node, and the island looks up its component by ID.

Prerequisites

  • Node.js 22.22 or later: Required by Astro 7.
  • An Astro project that renders on the server: Add the @astrojs/react integration and a server adapter, such as @astrojs/node.
  • @contentful/experiences-react 0.14.7 or later: The first version with the custom renderer.
  • A Content Delivery API access token: Find it in Settings > API keys in the Contentful web app. To render draft content, you also need the Content Preview API access token.

1. Install the SDK

Add the React and Node integrations to your Astro project, then install the React adapter:

npx astro add react node
npm install @contentful/experiences-react

Render pages on the server, so every request fetches the latest version of the experience:

// astro.config.mjs
import node from '@astrojs/node';
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
});

2. Register your components

Map each component ID authored in Contentful to a React component. Component keys match the last segment of each node’s componentType.sys.urn:

// src/lib/experience-config.ts
import type { Config } from '@contentful/experiences-react';
import { Card } from '../components/Card';
import { HeroPlain } from '../components/HeroPlain';
import { Page } from '../components/Page';
import { Section } from '../components/Section';
import { designTokens } from './design-tokens';
export const experienceConfig: Config = {
components: { Section, card: Card, 'hero-plain': HeroPlain },
experienceTemplates: { page: Page },
resolveToken: (token) => designTokens[token.value],
};

Components receive merged content and design values as ordinary props, so they don’t need to import the SDK. resolveToken maps design token IDs to values. In this example, designTokens is an object of token IDs and CSS values.

3. Choose the components that hydrate

List the components that must be interactive in the browser, and bind them with createExperienceNode:

// src/exo/ClientExperienceNode.tsx
import { createExperienceNode } from '@contentful/experiences-react/custom-renderer';
import { Card } from '../components/Card';
export const clientComponents = { card: Card };
export default createExperienceNode(clientComponents);

clientComponents declares hydration intent for your app. Only the components in it ship JavaScript to the browser. Every other component renders as static HTML.

Don’t pass experienceConfig.components to createExperienceNode. It works, but it bundles every registered component for the browser.

4. Render the experience tree

Create an Astro component that renders a list of nodes and calls itself for each slot:

---
// src/exo/ExperienceNodes.astro
import type { PortableRenderNode, RenderContext } from '@contentful/experiences-react';
import { ExperienceNode, resolveNodeProps } from '@contentful/experiences-react/custom-renderer';
import { experienceConfig } from '../lib/experience-config';
import ClientExperienceNode, { clientComponents } from './ClientExperienceNode';
const { nodes, experience } = Astro.props as {
nodes: PortableRenderNode[];
experience: RenderContext;
};
---
{
nodes.map((node) => {
const resolved = resolveNodeProps(node, experienceConfig);
if (resolved.status === 'unregistered') {
console.warn(`No component registered for "${resolved.id}" (node ${resolved.nodeId})`);
return null;
}
const slots = Object.entries(node.slots);
return resolved.id in clientComponents ? (
<ClientExperienceNode client:visible node={resolved.snapshot} experience={experience}>
{slots.map(([name, children]) =>
name === 'children' ? (
<Astro.self nodes={children} experience={experience} />
) : (
<Fragment slot={name}>
<Astro.self nodes={children} experience={experience} />
</Fragment>
)
)}
</ClientExperienceNode>
) : (
<ExperienceNode node={resolved} experience={experience}>
{slots.map(([name, children]) =>
name === 'children' ? (
<Astro.self nodes={children} experience={experience} />
) : (
<Fragment slot={name}>
<Astro.self nodes={children} experience={experience} />
</Fragment>
)
)}
</ExperienceNode>
);
})
}

For each node, the component:

  1. Calls resolveNodeProps to look up the registered component and merge its props. It logs and skips nodes with no registered component.
  2. Renders components in clientComponents as islands with client:visible, passing resolved.snapshot.
  3. Renders every other component as static HTML with ExperienceNode.
  4. Renders each slot by calling itself with Astro.self, so nested nodes follow the same rules.

experience publishes the render context that useExperience() reads. React context doesn’t cross island boundaries, so ExperienceNode sets up the SDK context again inside every island.

Keep the slot markup as shown:

  • Write each <Fragment slot> directly inside the component tag. If you build the fragments in a variable first, Astro moves them to the default slot.
  • Pass the children slot through Astro’s default slot. As a named slot, it’s overwritten when an island hydrates, and the island’s nested nodes disappear.

5. Fetch and render the experience

Fetch the experience on the server and pass its nodes and render context to ExperienceNodes:

---
// src/pages/[slug].astro
import { fetchExperience, NotFoundError } from '@contentful/experiences-react';
import ExperienceNodes from '../exo/ExperienceNodes.astro';
import { experienceConfig } from '../lib/experience-config';
const experienceId = Astro.params.slug!;
let plan;
try {
plan = await fetchExperience(
{
spaceId: import.meta.env.SPACE_ID,
environmentId: import.meta.env.ENVIRONMENT_ID,
experienceId,
locale: 'en-US',
},
{ accessToken: import.meta.env.CDA_TOKEN },
{ config: experienceConfig }
);
} catch (error) {
if (error instanceof NotFoundError) {
return new Response('Not found', { status: 404 });
}
throw error;
}
---
<!doctype html>
<html lang="en">
<body>
<ExperienceNodes nodes={plan.nodes} experience={{ debug: plan.debug, metadata: plan.metadata }} />
</body>
</html>

fetchExperience runs on the server, so your access token never reaches the browser. The experience is returned in the requested locale.

Add your credentials to a .env file in your project root:

SPACE_ID=<space-id>
ENVIRONMENT_ID=<environment-id>
CDA_TOKEN=<content-delivery-api-token>

6. Run your app

Start the development server:

npm run dev

Open http://localhost:4321/<experience-id>. Static components render as HTML, and the components in clientComponents hydrate when they scroll into view.

Inspect what renders

resolveNodeProps returns the final values for each node, so your app can inspect, log, or validate what renders before it reaches the page:

  • Component: resolved.id is the registered component ID, and resolved.component is your React component.
  • Props: resolved.props holds the merged values your component receives. Content is already localized for the requested locale.
  • Variant: resolved.contentful.attribution identifies the experience or experience fragment a node belongs to, the variant that was selected, and the bound entries. Attribution is only available when you request the content source map, by adding extensions: { sourceMap: {} } to the first fetchExperience argument.
  • Unregistered components: Nodes with no registered component return status: 'unregistered', so you can log them, render a fallback, or fail the request instead of rendering a silent gap.
  • Resolve-time errors: plan.diagnostics lists the problems found while resolving the experience, such as a failing resolveData function or an unresolved design token.
  • Content binding errors: plan.deliveryDiagnostics lists the fields the Experience Delivery API couldn’t resolve, such as a content binding to a missing entry, as fieldPath and message pairs. Those fields arrive as null. The messages can contain private data, so log a count or a generic warning by default, and keep the details out of client payloads.

To see the resolved experience on the page, mount DebugExperience from @contentful/experiences-react after the tree:

<DebugExperience experience={plan} errors={plan.diagnostics} />

Render draft content

To render saved but unpublished changes, fetch from the Content Preview API. Add your Content Preview API access token to .env:

CPA_TOKEN=<content-preview-api-token>

Pass the token and a preview flag to fetchExperience. This example enables preview with a ?preview=true query parameter:

const preview = Astro.url.searchParams.get('preview') === 'true';
plan = await fetchExperience(
{
spaceId: import.meta.env.SPACE_ID,
environmentId: import.meta.env.ENVIRONMENT_ID,
experienceId,
locale: 'en-US',
},
{ accessToken: import.meta.env.CDA_TOKEN, previewToken: import.meta.env.CPA_TOKEN, preview },
{ config: experienceConfig }
);
if (preview) Astro.response.headers.set('cache-control', 'no-store');

The cache-control header keeps draft content out of shared caches.

To open drafts from the Contentful web app, add a preview URL such as https://your-website.example/{entity.sys.id}?preview=true. See Add preview URLs.

Draft content shows changes after they’re saved. To show unsaved canvas edits as you make them, use session-based live preview. See Set up live preview.

Insert your own components

Because your app walks the experience tree, it can render components that aren’t authored in Contentful between any two nodes, such as a chat widget. Assign the rendered node to a variable, then render your component after the node it should follow:

---
// src/exo/ExperienceNodes.astro
import { ChatWidget } from '../components/ChatWidget';
// ...the imports from step 4
---
{
nodes.map((node) => {
// ...resolve the node as in step 4
const view = resolved.id in clientComponents ? (
<ClientExperienceNode client:visible node={resolved.snapshot} experience={experience}>
{/* slots */}
</ClientExperienceNode>
) : (
<ExperienceNode node={resolved} experience={experience}>{/* slots */}</ExperienceNode>
);
return (
<>
{view}
{resolved.id === 'hero-plain' && <ChatWidget client:idle />}
</>
);
})
}

The chat widget renders right after the hero, wherever the hero is in the tree. An inserted component can be static or an island, like any other Astro component.

Compared to ServerExperienceRenderer

The custom renderer renders components the same way the SDK renderers do:

  • Props: The same lookup and merge order: defaults, design values, content, then resolveData output.
  • Design tokens and resolveData: Resolved by fetchExperience, the same as for the SDK renderers.
  • SDK hooks: useExperience, useContentfulComponent, and useDesignValues work in static components and inside islands.
  • Error handling: A component that throws renders a fallback instead of breaking the page.

Your app handles the rest:

  • Unregistered components: ServerExperienceRenderer renders a placeholder for an unregistered component in debug mode, and renders the content of an unregistered experience template without its wrapper. Choose your own fallbacks for status: 'unregistered'.
  • Debugging: ServerExperienceRenderer mounts DebugExperience when debug is on and collects render-time errors. Mount DebugExperience yourself, and log what you need from resolveNodeProps.
  • Live preview: The custom renderer renders a fetched experience. For unsaved canvas edits, see Set up live preview.

Limitations

  • Island props must be serializable: A snapshot carries the output of your resolveData functions, so return plain data from them.
  • Slot content reaches an island as a single HTML element: The SDK renderers pass slot content as an array of React nodes. A component that maps over its slot children behaves differently inside an island.
  • Each client:* directive needs its own branch: Astro reads directives at build time. To hydrate some components with client:load and others with client:visible, add a branch for each.
  • Context doesn’t flow between islands: Each island gets its own SDK context. State shared between islands needs a store outside React context.

Troubleshooting

What you seeWhat to check
A component renders but doesn’t respond to clicks.Add its component ID to clientComponents. Components outside it render as static HTML.
Nested components disappear when an island hydrates.Pass the children slot through Astro’s default slot, as in step 4.
A named slot, such as content, renders empty.Write each <Fragment slot> directly inside the component tag, not in a variable.
An island throws “is not in its components”.Add the component to the map passed to createExperienceNode.
A component throws “useExperience() must be called inside…”.Pass experience to ExperienceNode and to your island component.
Content props are null.Check plan.deliveryDiagnostics for fields the Experience Delivery API couldn’t resolve, then fix the content bindings of the experience in Contentful. Check plan.diagnostics for resolve-time errors.
fetchExperience throws NotFoundError.Check the experience ID, space, and environment. An unpublished experience is only available with preview set to true.

Next steps