> This page is for Experience Orchestration.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://contentful.com/developers/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://contentful.com/developers/docs/_mcp/server.

# Render experiences in Astro

> Render experiences in Astro with the Experiences SDK custom renderer, so interactive components hydrate as Astro islands.

> **Info**
>
> 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](https://github.com/contentful/experiences/tree/main/examples/astro).

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:

| Export                 | Description                                                                                                                                         |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolveNodeProps`     | Looks up the component registered for a node and returns its final props. Returns `status: 'unregistered'` for a node with no registered component. |
| `ExperienceNode`       | Renders one resolved node the way the SDK renderers do, including the context that SDK hooks read.                                                  |
| `createExperienceNode` | Returns 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:

```sh
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:

```js
// 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`:

```ts
// 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`:

```tsx
// 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.

> **Info**
>
> 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:

```astro
---
// 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`:

```astro
---
// 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:

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

## 6. Run your app

Start the development server:

```sh
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:

```astro
<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`:

```sh
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:

```ts
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](/developers/docs/experience-orchestration/experiences-sdk/live-preview#add-preview-urls).

> **Info**
>
> 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](/developers/docs/experience-orchestration/experiences-sdk/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:

```astro
---
// 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](/developers/docs/experience-orchestration/experiences-sdk/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 see                                                 | What 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

* [Experiences SDK](/developers/docs/experience-orchestration/experiences-sdk/overview) for installation and the SDK renderers.
* [Set up live preview](/developers/docs/experience-orchestration/experiences-sdk/live-preview) to show unsaved canvas edits on your website.
* **[Astro example](https://github.com/contentful/experiences/tree/main/examples/astro)**: A complete app with an interactive component, draft content, and an inserted component.