Render content in a static site
This page takes you from an empty directory to a Vite and React site that renders a page published in your own ebitex organization. There is no server of your own anywhere in it: the browser calls the Content Delivery API directly, and the built site can be served from any static host or CDN.
A static site is a complete, fully supported way to use Content, not a stepping stone. If you want content in the delivered HTML, real status codes, or a key that never reaches a browser, see Render content on a server. Both use the same renderers.
For a finished site built this way, see the
northwind-coffee sample:
a coffee roaster’s catalogue, guides and store list, with seed content you can import into your own
organization.
What you need
Section titled “What you need”- An ebitex account and organization. Set up your organization covers signing up and where each key is created.
- Node 20 or newer.
- An ebitex organization with at least one published page. A page is an Experience node in
Composer whose Presentation names a Template and binds a Component. If you have nothing yet, the
northwind-coffeesample’sseed/final.zipbundle creates a whole content model. In Content, open Tools → Transfer, select Upload bundle file…, then Import… next to the staged bundle, and choose Fresh identity — the bundle comes from another organization, and the default, Preserve identity, is for moving content within your own. An import creates drafts, so publish the site’s pages afterwards. - The external id of each Template your pages use. You will name a renderer after each one.
Create a browser-safe delivery key
Section titled “Create a browser-safe delivery key”A static site’s JavaScript is public, so any key you put in it is public too. Content has a kind of delivery key made for exactly that.
- In Content, open Settings → API Keys.
- Name the key and turn on Browser-safe key.
- Under allowed origins, list every origin the site runs on:
http://localhost:5173for Vite’s development server, and your production origin, such ashttps://www.example.com. - Select Create key and copy it. It is shown once.
Shipping this key in a bundle is safe for two reasons. A delivery key can only read content you have already published, which is public on your site anyway. And a browser-safe key only answers requests from the origins (and IP ranges) you listed, so other sites cannot use it from their own visitors’ browsers. It identifies your site rather than guarding a secret.
A key created with Browser-safe key turned off is a server-side key. It works from anywhere, so it must never reach a browser. The SDK cannot tell the two kinds apart, because a key is only a string: keeping a server-side key out of your bundle is up to you.
Create the project
Section titled “Create the project”npm create vite@latest my-site -- --template react-tscd my-sitenpm installnpm install @ebitex/content-sdk@ebitex/content-sdk needs React 19 or newer, which the Vite template installs.
Put the key in .env.local, which Vite’s template already ignores in git:
VITE_CONTENT_DELIVERY_KEY=<your browser-safe key>The VITE_ prefix tells Vite to inline the value into the bundle. For this key that is what you
want. Never give a server-side key a VITE_ prefix.
Render the page at the current address
Section titled “Render the page at the current address”Replace src/main.tsx with this:
// A whole Vite and React site that renders whatever ebitex Content has published at the current// address. This is `src/main.tsx` in a project created from Vite's `react-ts` template.import { StrictMode } from 'react'import { createRoot } from 'react-dom/client'import { createContentClient } from '@ebitex/content-sdk'import { ContentProvider, Experience, renderersFromGlob } from '@ebitex/content-sdk/react'
// One client for the whole tab. The key is a browser-safe delivery key, so it is safe to ship in// this bundle: it only works from the origins you listed when you created it.const content = createContentClient({ apiKey: import.meta.env.VITE_CONTENT_DELIVERY_KEY,})
// One file per Template under `src/presentations/`, named by the Template's external id, with the// renderer as its default export: `presentations/hero.tsx` renders the Template `hero`. The second// pattern keeps test files from being registered as Templates.const renderers = renderersFromGlob(import.meta.glob(['./presentations/*.tsx', '!./presentations/*.test.tsx']))
function App() { return ( <ContentProvider client={content} renderers={renderers}> <Experience path={window.location.pathname} // The SDK reports a redirect and never follows it. Without a router, a full navigation is // all following it takes. onRedirect={(target) => window.location.replace(target)} notFound={<p>Nothing is published at this address.</p>} /> </ContentProvider> )}
createRoot(document.getElementById('root')!).render( <StrictMode> <App /> </StrictMode>,)Three pieces do the work.
createContentClient builds the client. On each resolve it decides three things so you don’t
have to:
- The site. If your organization publishes one site, that site. With several, the one whose host
mapping (in Content → Configure → Sites) contains the page’s hostname. Pass
sitewith a site’s root node id to choose one explicitly, which you need during local development if more than one site is published. - The locale. The first of the visitor’s browser languages that exists in your organization’s
locale tree, else your organization’s default. Pass
localeto choose one. - The personalization context. Pass
contextwith the facts your site knows about the visitor, such as{ audiences: ['returning'] }. Content decides what those facts mean.
<ContentProvider> makes the client and your renderers available to everything below it.
<Experience path> resolves the path, and renders the Presentation found there by calling the
renderer registered for its Template. A Presentation inside that content is dispatched the same way,
all the way down. Pass notFound for an address where nothing is published, and onRedirect for an
address the CMS redirects, such as the old slug of a renamed page. <Experience> never follows a
redirect itself.
This example reads window.location.pathname, so every navigation is a full page load. With a
router, pass the router’s pathname to path instead (useLocation().pathname in React Router), and
navigate in onRedirect. The previous page stays visible while the next one loads.
Add one renderer per Template
Section titled “Add one renderer per Template”A renderer is a React component that receives one Presentation: the Template, its settings, and the
component (the content) it presents. Create src/presentations/hero.tsx:
// The renderer for the Template whose external id is `hero`. The file name is the registration and// the default export is the renderer.import type { RichTextValue } from '@ebitex/content-sdk'import { RichText, type PresentationRenderer } from '@ebitex/content-sdk/react'
// The fields of the Contract this Template presents, keyed by each field's external id. Written by// hand here; generated types can replace it.interface Statement { heading: string body?: RichTextValue}
// The Template's own settings, which an author chooses each time they use it.interface HeroSettings { align?: 'center' | 'start'}
// `component.content` is always present when a renderer is called. Content that cannot be resolved// renders a fallback instead, so a renderer never checks for it.const Hero: PresentationRenderer<Statement, HeroSettings> = ({ component, settings }) => ( <section style={{ textAlign: settings.align ?? 'center' }}> <h1>{component.content.heading}</h1> <RichText fragments={component.content.body} /> </section>)
export default HeroThe file name is the registration. renderersFromGlob in src/main.tsx takes each file under
src/presentations/, strips the extension, and registers the default export under that name, so
hero.tsx renders every Presentation whose Template’s external id is hero. Add one file for each
Template your pages use.
Inside a renderer:
component.contentholds the Contract’s fields, keyed by each field’s external id. It is always present.settingsholds the Template’s settings for this use of it.<RichText fragments>renders a RichText field. Until you pass a markdown renderer to<ContentProvider markdown>, its markdown shows as source in a panel. Content’s markdown is CommonMark plus GFM tables and strikethrough, and nothing else, so configure your markdown library for exactly those two extensions.<PresentationList items>renders a field that holds a list of Presentations, dispatching each one to its own renderer.
Hand-written field types like the Statement interface drift from your content model as it
changes. Generate them instead once the model settles.
Run it
Section titled “Run it”npm run devOpen http://localhost:5173/ for your site’s root page, or the path of any published page.
A Template with no renderer
Section titled “A Template with no renderer”When a Presentation names a Template you have not written a renderer for, the page still renders,
and that Presentation shows a panel in its place. The panel’s heading reads, for example,
Presentation "card" has no renderer. Below it the panel names the fix (add
presentations/card.tsx with a default export), the Contract the content uses and its field names,
and the whole delivered JSON for that Presentation. That is usually everything you need to write the
renderer.
The panel appears for other problems too, each with its own heading and fix. The most common is content bound to a Component that is not published: publish it, or bind another.
The panel renders in production as well, because a visible gap is easier to find than a missing
section. Before you launch, replace it with something that suits your site by passing your own
component to <ContentProvider fallback>, or () => null to render nothing.
Errors from the API
Section titled “Errors from the API”A failed request renders a similar panel naming the error code and what to do about it. The two you are most likely to meet first:
| Code | What to do |
|---|---|
origin_denied |
Add the page’s origin to the key’s allowed origins, in Settings → API Keys. |
invalid_api_key |
The key is wrong, revoked or expired. Check .env.local, or create a new key. |
Replace this panel with <ContentProvider errorFallback>.
Deploy it
Section titled “Deploy it”npm run buildServe the dist/ directory from any static host, and tell the host two things:
- Rewrite every path to
/index.html. The CMS decides what exists at each address, so every path is served by the same page. Without the rewrite, the host answers 404 for/aboutbefore your code runs. - Your production origin belongs in the key’s allowed origins. Otherwise every request from the
deployed site fails with
origin_denied.
Next steps
Section titled “Next steps”- Render content on a server: the same renderers, with content in the delivered HTML.
- Generate types from your content model.
- The
northwind-coffeesample shows much more of Content, step by step: localization, personalization, Adapters, streams and a build-time sitemap. Each step is a git tag, and a matching series on the ebitex blog explains it. - The
GET /pathreference describes the request the client makes for each page.