Skip to content

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.

  • 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-coffee sample’s seed/final.zip bundle 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.

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.

  1. In Content, open Settings → API Keys.
  2. Name the key and turn on Browser-safe key.
  3. Under allowed origins, list every origin the site runs on: http://localhost:5173 for Vite’s development server, and your production origin, such as https://www.example.com.
  4. 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.

Terminal window
npm create vite@latest my-site -- --template react-ts
cd my-site
npm install
npm 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:

Terminal window
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.

Replace src/main.tsx with this:

src/main.tsx
// 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 site with 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 locale to choose one.
  • The personalization context. Pass context with 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.

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:

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 Hero

The 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.content holds the Contract’s fields, keyed by each field’s external id. It is always present.
  • settings holds 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.

Terminal window
npm run dev

Open http://localhost:5173/ for your site’s root page, or the path of any published page.

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.

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>.

Terminal window
npm run build

Serve 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 /about before your code runs.
  • Your production origin belongs in the key’s allowed origins. Otherwise every request from the deployed site fails with origin_denied.