A minimal Next.js app demonstrating @contentful/experiences-react
rendering a Contentful Experience, styled by a tiny hand-rolled design system
of CSS variables.
The point of this repo is to learn the basics of fetching and rendering a Contentful Experience, and how a simple design system can plug into Contentful's Experience Orchestration (ExO) tooling to get an ExO project up and running quickly. The 4 registered components are as small as possible so that pipeline — Experience in, rendered page out — stays visible.
npm install
cp .env.example .env.local
npm run devFill in .env.local:
| Var | Meaning |
|---|---|
SPACE_ID |
Contentful space id |
ENVIRONMENT_ID |
Contentful environment id (defaults to master) |
CDA_TOKEN |
Content Delivery API access token, used for published content |
CPA_TOKEN |
Content Preview API access token, used when Draft Mode is on |
DRAFT_MODE_SECRET |
Secret required to turn on Draft Mode (pick any string) |
/[locale]/[id]— renders the Experience with ididin localelocale, e.g./en-US/homepage.notFound()if the Experience doesn't exist./api/draft/enable?secret=<DRAFT_MODE_SECRET>&locale=<locale>&id=<id>— turns on Draft Mode (fetches with the preview token instead of the delivery token) and redirects to/<locale>/<id>./api/draft/disable— turns off Draft Mode and redirects back.
Three pieces make up the whole design system:
-
app/globals.css— a single Tailwind v4@themeblock. Each entry (--color-primary,--spacing-md,--text-lg,--radius-sm, ...) becomes a real CSS custom property on:root. This is the only place actual values (colors, rem sizes) live — change a value here and every component that uses that token updates. -
lib/design-tokens.ts— the bridge between Contentful and those CSS variables. It exports:designTokens: a map from Contentful token id ("color.primary") to the matching CSS variable ("var(--color-primary)").resolveDesignToken: passed to the SDK asconfig.resolveToken(see below) — called for every token-type design prop on an Experience node and turns its id into a CSS variable.- Small helpers components call directly for their non-token props:
textColor,textSize,borderRadius,spacing,foregroundForBackground(picks a readable text color for a given background).
-
components/primitives/*.tsx(Button,Text,Flex,Image) — each imports only the helpers it needs fromdesign-tokens.tsand declares its own curated props. No genericstyleprop, so an Experience can't push arbitrary CSS through a component that wasn't designed for it:// Button.tsx import { designTokens, foregroundForBackground } from "@/lib/design-tokens"; export function Button({ color = designTokens["color.primary"], size = "md", ... }) { const style = { backgroundColor: color, color: foregroundForBackground(color), // ...radius/spacing/fontSize from the size preset }; }
If you add a new component or a new token, the flow is always the same:
add the raw value to globals.css, add its Contentful token id to
designTokens in design-tokens.ts, then consume it in the component via
the existing helpers (or a new one, colocated in design-tokens.ts).
-
lib/experience-config.tsregisters the components with the SDK and wires in the token resolver — the one place that ties everything above together:export const experienceConfig: Config = { components: { Button, Text, Flex, Image }, resolveToken: resolveDesignToken, };
-
app/[locale]/[id]/page.tsxis the whole render path for a page:fetchExperience({ spaceId, environmentId, experienceId, locale }, { accessToken, previewToken, preview }, { config: experienceConfig, debug: preview })— fetches the Experience by id/locale from Contentful (CDA, or CPA when Draft Mode is on), returningnullif it doesn't exist.<ServerExperienceRenderer experience={experience} config={experienceConfig} debug={preview} />— renders it server-side using the sameexperienceConfig, resolving design tokens and mapping each node to its registered component.
Both calls take the same
config, so registering a component or a token once inexperience-config.ts/design-tokens.tsis enough for it to show up correctly in both preview and published rendering.
Not the main focus of this repo — included to show how an Experience can sit
inside a larger page shell, including chrome that might fetch its own data
directly from Contentful entries rather than through an Experience.
components/chrome/SiteHeader.tsx and SiteFooter.tsx are hardcoded here
(no CMS content) and built only from the same 4 primitives.
app/[locale]/[id]/layout.tsx reads draftMode() and passes it to
SiteHeader, which shows a banner and an "Exit draft mode" button whenever
Draft Mode is on.
Providing some code snippets for easy copy/paste while going through hand ons training.
Preview URL
http://localhost:3000/api/draft/enable?secret=this-is-a-secret&locale={locale}&id={experience.sys.id}
Experience Fetching
const experience = await fetchExperience(
{
spaceId: process.env.SPACE_ID!,
environmentId: process.env.ENVIRONMENT_ID!,
experienceId: id,
locale,
},
{
accessToken: process.env.CDA_TOKEN!,
previewToken: process.env.CPA_TOKEN,
preview,
},
{ config: experienceConfig, debug: preview },
);
if (!experience) notFound();
Experience Rendering
<ServerExperienceRenderer experience={experience} config={experienceConfig} debug={preview} />
Experience Config
export const experienceConfig: Config = {
components: {
Button,
Text,
Flex,
Image,
},
resolveToken: resolveDesignToken,
};