Docs / Build
Client SDK
@edgecms/client is a small TypeScript package for frontends that read EdgeCMS content. It wraps the public delivery routes, renders TipTap/ProseMirror rich text, and carries live draft updates from the admin editor into a preview iframe.
Source: packages/client/src. Tests: packages/client/src/__tests__.
Install status
@edgecms/client is not published to npm. Its package.json has "private": true, and bun run check:package fails if that flag is removed. The package README shows bun add @edgecms/client; that command will not work until the package is published.
Ways to use it today:
Inside this monorepo (how apps/admin uses it):
{
"dependencies": {
"@edgecms/client": "workspace:*"
}
}bun install runs the package's postinstall, which builds packages/client/dist. Rebuild after changing the source:
bun run --filter @edgecms/client build
bun run test:clientIn a separate project, pack a tarball from your checkout and install it with scripts disabled:
cd packages/client
npm pack # writes edgecms-client-0.2.0.tgz
cd /path/to/your-app
npm install --ignore-scripts /path/to/edgecms-client-0.2.0.tgz
npm install react # needed even if you only use the fetch client, see belowTwo known gaps make the tarball awkward:
--ignore-scriptsis required. Thepostinstallscript runsnode scripts/build.mjs, butscripts/is not in the packagefileslist, so a normal install fails withMODULE_NOT_FOUND.- The root entry re-exports the React component and the
useDraftPreviewhook, soimport ... from '@edgecms/client'needsreactto be resolvable. The package lists React as an optional peer dependency, but the root import fails without it.
Exports
From @edgecms/client:
| Export | Kind | Purpose |
|---|---|---|
createEdgeCmsClient, EdgeCmsClient | fetch client | Read collections, entries and singletons; build asset URLs; request preview tokens. |
renderContentToHtml | function | TipTap/ProseMirror JSON (or a JSON string, or an HTML string) to HTML. |
sanitizeHtmlString | function | Strips script, style, iframe, object, embed, svg and math tags, on* handlers and javascript:/vbscript:/data: URLs. |
EdgeContent | React component | Renders the same rich-text JSON as React elements, with per-node overrides. |
sendDraftPreviewUpdate, sendPreviewReadyNotification, listenToDraftPreview | functions | window.postMessage bridge for live draft preview. |
useDraftPreview | React hook | Subscribes a preview page to draft updates. |
EDGECMS_PREVIEW_MESSAGE_TYPE, EDGECMS_PREVIEW_READY_TYPE | constants | 'EDGECMS_PREVIEW_UPDATE' and 'EDGECMS_PREVIEW_READY'. |
| Types | types | EdgeCmsClientConfig, CollectionListOptions, PaginatedListResponse, AssetUrlOptions, TipTapNode, and others in types.ts. |
From @edgecms/client/react: EdgeContent and its prop types only.
The package has no Section Builder export. See Section Builder content below.
Fetch client
import { createEdgeCmsClient } from '@edgecms/client'
const cms = createEdgeCmsClient({
baseUrl: process.env.EDGE_CMS_URL!, // e.g. http://localhost:8787
tenantSlug: 'acme',
defaultLocale: 'en', // default 'en'
// apiKey: '...', // sent as Authorization: Bearer <apiKey>
// headers: { ... }, // merged into every request
// fetch: customFetch, // defaults to globalThis.fetch
})baseUrl and tenantSlug are required; the constructor throws without them. A trailing slash on baseUrl is removed.
Reads
const posts = await cms.collection('posts').list({
page: 1,
perPage: 10,
sort: '-publishedAt',
filter: { category: 'news' },
})
const post = await cms.collection('posts').get('welcome-post', { populate: 'author' })
const settings = await cms.collection('site-settings').getSingleton()These call the tenant-scoped public routes (see Public API Guide):
GET /api/tenants/:tenantSlug/public/:collectionSlug
GET /api/tenants/:tenantSlug/public/:collectionSlug/:entryIdOrSlug
GET /api/tenants/:tenantSlug/public/singleton/:collectionSlug/api/tenants/:tenantSlug/public/... is an alias the API rewrites to /api/tenants/:tenantSlug/api/public/..., so both forms reach the same handler.
Query parameters sent by the client: locale (always, falling back to defaultLocale), page, perPage, sort, depth, populate (on get), and each filter key as its own parameter. Which filters the server accepts is set by the public filter allowlist.
A non-2xx response throws an Error whose message includes the status and the response body, for example EdgeCMS list request failed (404): ....
list() is typed as PaginatedListResponse<T> ({ data, meta?, pagination? }). Keep parsing tolerant, as Frontend Integration recommends, until the public response envelope is versioned.
Public reads need no credentials. apiKey is only forwarded as a bearer header; the public read routes do not check it.
Asset URLs
getAssetUrl builds a URL string and makes no request:
cms.getAssetUrl(asset.id)
// .../api/tenants/acme/public/assets/<id>/original
cms.getAssetUrl(asset.id, { variant: 'medium', format: 'webp' })
// .../api/tenants/acme/public/assets/<id>/medium.webp
cms.getAssetUrl(asset.id, { variant: 'large', width: 800, fit: 'cover' })
// .../api/tenants/acme/public/assets/<id>/large?w=800&fit=coverWhat each form returns (format negotiation, fallbacks, transforms) is described in Media.
Preview tokens
const { token, expiresAt } = await cms.createPreviewToken({
collectionSlug: 'posts',
entrySlug: 'welcome-post',
previewUrl: 'https://www.example.com/blog/welcome-post',
ttlSeconds: 600,
})This sends POST /api/tenants/:tenantSlug/public/preview-token. On the tenant-scoped path, a POST counts as a tenant admin mutation, so the caller must be signed in with a role that can mutate tenant admin routes (owner, admin or editor). Call it from server code or the admin, not from a public browser bundle.
list, get and getSingleton accept a previewToken option. The client sends it as the previewToken query parameter and the x-edgecms-preview-token header, but the public read handlers do not read either today: they return published content only. Draft content reaches a preview page through the postMessage bridge below, not through these reads. The Preview rules in the Public API Guide still apply.
Rich text
import { renderContentToHtml } from '@edgecms/client'
const html = renderContentToHtml(post.content)Supported nodes: doc, paragraph, heading (levels 1-6), blockquote, bulletList, orderedList, listItem, codeBlock, image, horizontalRule, hardBreak, text. Supported marks: bold/strong, italic/em, strike/s, code, underline/u, link, highlight, subscript/sub, superscript/sup.
Text is HTML-escaped. Link and image URLs must start with http://, https://, mailto:, tel:, /, #, ./ or ../; anything else becomes #. Links with target="_blank" get rel="noopener noreferrer". Unknown nodes render their children.
Add your own nodes with customRenderers (from serializer.test.ts):
const html = renderContentToHtml(doc, {
customRenderers: {
callout: (node) => `<div class="callout-${node.attrs?.type}">${node.text}</div>`,
},
})A custom renderer's output is inserted as-is. Escape any text you put in it.
String input is handled two ways: a string that parses as a JSON object is rendered as a document; any other string is treated as HTML and passed through sanitizeHtmlString. That sanitizer is a set of regular expressions, not a full HTML parser. If you render HTML from untrusted authors, run it through a dedicated sanitizer as well.
React component
import { EdgeContent } from '@edgecms/client/react'
export function PostBody({ post }) {
return <EdgeContent content={post.content} className="prose" />
}Override node rendering with components (from react.test.tsx):
<EdgeContent
content={post.content}
components={{
heading: ({ level, children }) => <div className={`custom-h${level}`}>{children}</div>,
codeBlock: ({ language, code }) => <pre data-lang={language}>{code}</pre>,
}}
/>Overridable keys: paragraph, heading, blockquote, bulletList, orderedList, listItem, codeBlock, image, link. A string content value is sanitized with sanitizeHtmlString and rendered as HTML.
Live draft preview bridge
The admin entry editor streams the unsaved form to a preview iframe with window.postMessage. Nothing is written to the database for these updates.
Message shapes:
{ "type": "EDGECMS_PREVIEW_UPDATE", "token": "<optional>", "payload": { "...": "..." } }
{ "type": "EDGECMS_PREVIEW_READY" }The admin sends this payload (see apps/admin/src/features/entries/components/entry-form.tsx):
{
"slug": "welcome-post",
"status": "draft",
"data": { "title": "...", "content": { "type": "doc", "content": [] } },
"locale": "en",
"collectionSlug": "posts",
"updatedAt": "2026-10-22T08:00:00.000Z"
}In the preview page:
import { useDraftPreview } from '@edgecms/client'
export function PostPreview({ published }) {
const { data, isPreview } = useDraftPreview(published, {
allowedOrigin: 'https://admin.example.com',
})
return <Post entry={data} draft={isPreview} />
}Without React:
import { listenToDraftPreview } from '@edgecms/client'
const stop = listenToDraftPreview((draft) => render(draft), {
allowedOrigin: 'https://admin.example.com',
})listenToDraftPreview posts EDGECMS_PREVIEW_READY to window.parent when it starts. The admin answers by sending the current draft.
Security notes:
- Always set
allowedOriginto your admin's origin. Without it, the listener accepts messages from any window. - The
tokenoption only rejects messages that carry a different token. A message with no token is still accepted. - The admin posts drafts with
targetOrigin: '*', so whatever page is loaded in the preview iframe receives them. Point the tenant's preview URL only at sites you control.
Section Builder content
The Section Builder is an admin field editor (sections, blocks or section-builder field types, in apps/admin/src/features/entries/components/field-renderers/section-builder.tsx). It stores an array of blocks typed in packages/schemas/src/block-sections.ts: hero, features, textBlock, imageGallery, callToAction, faq. Each block has an id and a type.
@edgecms/client does not render these blocks. Read the field from the entry and switch on type in your own components. @edgecms/schemas is also a private workspace package.
Checks
bun run test:client
bun run --filter @edgecms/client check # build, private flag, zero dependencies, dist imports