Studio
The full editor, mounted on your site, with your inputs and previews.
The Studio is the editing UI: content lists, the document editor, Media, chat, publish, and settings. The dashboard runs it. Your site can run it too, at /backmatter, built by your own bundler from @backmatter/studio.
That is what makes the dev bar work on a production domain. The login never crosses sites: the Studio on your site uses a session cookie on your origin and talks to a route on your origin. It is also what lets you change the editor for your content, with React components you write.
Mount it
backmatter init writes three files for a Next site:
export { StudioLayout as default } from "@backmatter/next/studio";
import { createStudioPage } from "@backmatter/next/studio";
import { cms } from "backmatter";
import studio from "../../../../backmatter.studio";
export default createStudioPage(cms, { studio });
"use client";
import { defineStudio } from "@backmatter/studio/define";
export default defineStudio({
menu: [{ label: "Open site", href: "/" }],
});
The route group has its own root layout, so your header, footer, fonts, and global CSS do not wrap the editor. @backmatter/next depends on @backmatter/studio; add it to your own package.json as well, since backmatter.studio.tsx imports it directly.
createStudioPage takes basePath (default /backmatter), proxyPath (default /api/backmatter/cms), previewEntryPath (default /api/backmatter/preview), and dashboardUrl (default: the Backmatter dashboard, or BACKMATTER_DASHBOARD_URL). The page is static HTML with no data in it. Without a session it redirects to the dashboard, which signs you in, mints a ticket, and returns.
How it signs in
| Step | Where |
|---|---|
You open /backmatter or the bar on your site | Your site |
No session: redirect to {dashboard}/enter?workspace=…&return=… | Dashboard, first-party cookie |
Dashboard mints a ticket and sends you to /api/backmatter/preview?ticket=… | Your site |
The ticket route sets backmatter_preview_session (HttpOnly, 12h) and returns you to return | Your site |
Every request from the Studio goes to /api/backmatter/cms/* on your site. That route turns the cookie into a bearer token for the CMS and forwards an allow-list of paths, scoped to the session's workspace, branch, and tree. Paths that change the workspace rather than its content (members, GitHub, host tokens) are not on the list; those stay in the dashboard.
return must be an origin in the workspace's site URLs. The dashboard refuses anything else.
defineStudio
backmatter.studio.tsx is a "use client" module. Its default export is passed to createStudioPage. The server only forwards the reference; the browser builds the components.
"use client";
import { defineStudio } from "@backmatter/studio/define";
import { ColorInput } from "./components/color-input";
import { HeroPreview } from "./blocks/hero.preview";
export default defineStudio({
theme: { accent: "#0f2a3f" },
fields: { color: ColorInput },
blocks: { Hero: { preview: HeroPreview, label: "Hero" } },
menu: [{ label: "Open site", href: "/" }],
});
| Key | What it does |
|---|---|
theme.accent | The accent color: buttons, focus rings, active states. |
fields | A React input per field. Keyed by the field's input hint first, then by its kind (text, number, …). |
blocks | Per block type: preview renders above the fields when a block is open, label and icon replace the type name in the list. |
menu | Links under Settings in the sidebar. |
Custom inputs
Ask for an input in the model, then provide it:
background: field.text({ input: "color" }),
import type { StudioFieldInputProps } from "@backmatter/studio/define";
export function ColorInput({ value, onChange, readOnly }: StudioFieldInputProps) {
const color = typeof value === "string" ? value : "#000000";
return (
<input type="color" value={color} disabled={readOnly} onChange={(e) => onChange(e.target.value)} />
);
}
StudioFieldInputProps is { name, field, value, readOnly, onChange }. field is the schema field (kind, required, input, …). The input is used wherever that field is edited: the document details, a block's fields, and the block settings dialog. The value you pass to onChange is what autosave writes.
A field with no input falls back to fields[kind], then to the built-in control.
Block previews
import type { StudioBlockPreviewProps } from "@backmatter/studio/define";
export function HeroPreview({ props }: StudioBlockPreviewProps) {
return <div>{String(props.headline ?? "")}</div>;
}
The preview receives the block's current props, so it follows typing. It is a picture for the editor, not the site's render; the page next to the Studio is still the preview of record.
Versions
The Studio on your site is the version in your package.json. The dashboard records the version each site last reported and shows it under Settings → General. When the CMS needs a newer Studio it answers 426, and the Studio on the site shows one notice: update @backmatter/studio and @backmatter/next, then reload.
Limits
- Signing in happens at the dashboard. There is no login form on your domain.
- A static export has no server, so no proxy and no cookie. The dashboard's split-view editor still works there; the bar does not.
- Chat tools are not customizable from the site.