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:
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/reactintegration and a server adapter, such as@astrojs/node. @contentful/experiences-react0.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:
Render pages on the server, so every request fetches the latest version of the experience:
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:
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:
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:
For each node, the component:
- Calls
resolveNodePropsto look up the registered component and merge its props. It logs and skips nodes with no registered component. - Renders components in
clientComponentsas islands withclient:visible, passingresolved.snapshot. - Renders every other component as static HTML with
ExperienceNode. - 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
childrenslot 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:
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:
6. Run your app
Start the development server:
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.idis the registered component ID, andresolved.componentis your React component. - Props:
resolved.propsholds the merged values your component receives. Content is already localized for the requested locale. - Variant:
resolved.contentful.attributionidentifies 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 addingextensions: { sourceMap: {} }to the firstfetchExperienceargument. - 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.diagnosticslists the problems found while resolving the experience, such as a failingresolveDatafunction or an unresolved design token. - Content binding errors:
plan.deliveryDiagnosticslists the fields the Experience Delivery API couldn’t resolve, such as a content binding to a missing entry, asfieldPathandmessagepairs. Those fields arrive asnull. 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:
Render draft content
To render saved but unpublished changes, fetch from the Content Preview API. Add your Content Preview API access token to .env:
Pass the token and a preview flag to fetchExperience. This example enables preview with a ?preview=true query parameter:
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:
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
resolveDataoutput. - Design tokens and
resolveData: Resolved byfetchExperience, the same as for the SDK renderers. - SDK hooks:
useExperience,useContentfulComponent, anduseDesignValueswork 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:
ServerExperienceRendererrenders 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 forstatus: 'unregistered'. - Debugging:
ServerExperienceRenderermountsDebugExperiencewhendebugis on and collects render-time errors. MountDebugExperienceyourself, and log what you need fromresolveNodeProps. - 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
resolveDatafunctions, 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 withclient:loadand others withclient: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
Next steps
- Experiences SDK for installation and the SDK renderers.
- Set up live preview to show unsaved canvas edits on your website.
- Astro example: A complete app with an interactive component, draft content, and an inserted component.