Connect a site built with code
Last updated: October 12, 2026
One System lets a business change its website by chat — with a preview of the real site, the owner’s approval and Undo. For websites built with code (React, Next.js, Vite — for example with Claude Code or Cursor), the editable content lives in One System and the site loads it through a small helper. Nothing is installed on the server, and One System never needs server, FTP or GitHub access.
Steps (about 5 minutes once the site follows the rules)
- Add the rules to the project so Claude Code or Cursor builds it the right way: for Claude Code, add onesystem-rules.md to your
CLAUDE.md; for Cursor, save onesystem.mdc as.cursor/rules/onesystem.mdc. - Copy the helper: content.ts and react.tsx into an
onesystem/folder. - Put every editable text, link, image and page title in
public/onesystem-content.json(start from the example) and show it withuseContent("…"). - Add the Site ID to the site’s
.env—VITE_ONESYSTEM_SITE(Vite) orNEXT_PUBLIC_ONESYSTEM_SITE(Next.js) — and the<meta name="onesystem-site">tag (see the rules), then deploy as usual: cPanel, a VPS, Vercel… anywhere. - The business clicks Check connection in One System (Settings → Website). One System opens the live site, confirms the Site ID and reads the content file.
Good to know
- The content file and the content One System serves are public — they are the website’s own text. Values are always plain text: render them as text, never as HTML.
- If One System doesn’t answer within 1.5 seconds, the helper shows the copy built into the site, so pages never go blank.
- Changes go live within seconds (Next.js: within about 30 seconds). Preview links (
?os_preview=…) show a waiting change only to the owner, for 30 minutes. - When you add items and deploy again, One System adds them; the owner’s earlier edits stay. Don’t rename keys of a live site.
- The owner can only change values. New sections, pages, design and layout stay with you — One System’s team sends such requests to you.
The rules
Rules for Claude Code — add them to your project's CLAUDE.md. Save as CLAUDE.md (add as a section). Download
# One System content — rules for this website (keep this section)
This website's editable content is managed by One System (https://copilot.onesystemcrm.com) so the business owner
can change it by chat, with a preview and Undo. Follow these rules every time you build or change a page.
## 1. Editable content lives in `public/onesystem-content.json`, never typed into components
Everything a business owner might want to change goes in that file:
- headings, paragraphs, button labels, prices, opening hours, offers
- phone, email, address, WhatsApp/booking/social links
- images people may swap (and a description for each image)
- each page's Google title and description
Layout, styles, class names, components and logic stay in code. Only content goes in the file.
## 2. The file's shape
```json
{
"version": 1,
"pages": [
{ "path": "/", "name": "Home" },
{ "path": "/about", "name": "About" }
],
"fields": {
"home.hero.title": { "label": "Home — main heading", "type": "text", "page": "/", "value": "Fresh bread every morning" },
"home.hero.text": { "label": "Home — text under the heading", "type": "paragraph", "page": "/", "value": "Baked in Austin since 1998." },
"home.hero.button": { "label": "Home — button", "type": "text", "page": "/", "value": "See our menu" },
"home.hero.button_link": { "label": "Home — button link", "type": "link", "page": "/", "value": "/menu" },
"home.hero.image": { "label": "Home — main photo", "type": "image", "page": "/", "value": "/images/hero.jpg" },
"home.hero.image_alt": { "label": "Home — main photo description", "type": "text", "page": "/", "value": "Loaves on the counter" },
"contact.phone": { "label": "Phone number", "type": "phone", "page": "*", "value": "+1 512 555 0142" },
"contact.email": { "label": "Email", "type": "email", "page": "*", "value": "hello@example.com" },
"seo./.title": { "label": "Home — Google title", "type": "seo_title", "page": "/", "value": "Fresh Bread in Austin | Example Bakery" },
"seo./.description": { "label": "Home — Google description", "type": "seo_description", "page": "/", "value": "Sourdough, pastries and coffee, baked fresh every morning." }
}
}
```
- **Keys:** short and stable, with dots for sections (`home.hero.title`, `about.team.1.name`, `contact.phone`).
Google title and description use `seo.<path>.title` and `seo.<path>.description`, for example `seo./about.title`.
Never rename a key once the site is live: a renamed item starts over with the file's text, and the owner loses their edit.
- **Types:** `text` (one line, up to 300 characters), `paragraph` (plain text, line breaks allowed, up to 2,000),
`link` (https://…, /page, #section, mailto: or tel:), `phone`, `email`, `image` (a path under `public/` like
`/images/hero.jpg`, or an https:// address), `seo_title`, `seo_description`.
- **label:** what the owner sees, in plain words (“Home — main heading”, “Footer — opening hours”).
- **page:** the page path where it shows, or `"*"` for the header, footer and contact details shown on every page.
List every page in `"pages"`.
- Values are plain text only: no HTML, no Markdown, no code.
- Items in a list (services, team members, prices) each get their own keys: `services.1.title`, `services.1.text`,
`services.2.title`…
## 3. Show content only through the One System helper
The helper files are `onesystem/content.ts` and `onesystem/react.tsx` (from https://copilot.onesystemcrm.com/developers).
```tsx
const title = useContent("home.hero.title");
return <h1>{title}</h1>;
```
- Always render values as text: `{value}`. Never use `dangerouslySetInnerHTML`, `innerHTML`, `eval`, or put a value
into `style`, a class name, a script or a URL that isn't a `link`/`image` item.
- Images the owner may swap live in `public/images/` (not imported from `src/`), referenced by the `image` item,
with the `…_alt` item as the `alt` text.
- `paragraph` values may contain line breaks: show them with CSS `white-space: pre-line`.
## 4. Setup (once per project)
- **Site ID:** One System → Settings → Website → “Connect a site built with code” shows it (`os_` + 10 letters/digits).
- Vite / React: `.env` → `VITE_ONESYSTEM_SITE=os_xxxxxxxxxx`, and in `index.html`:
`<meta name="onesystem-site" content="%VITE_ONESYSTEM_SITE%">`
- Next.js: `.env` → `NEXT_PUBLIC_ONESYSTEM_SITE=os_xxxxxxxxxx`, and in `app/layout.tsx` metadata:
`other: { "onesystem-site": process.env.NEXT_PUBLIC_ONESYSTEM_SITE ?? "" }`
- **Vite / React** `src/main.tsx`:
```tsx
import contentFile from "../public/onesystem-content.json";
import { OneSystemContent } from "../onesystem/react";
createRoot(document.getElementById("root")!).render(
<OneSystemContent site={import.meta.env.VITE_ONESYSTEM_SITE} file={contentFile as never}><App /></OneSystemContent>
);
```
On each page component call `useSeo("/about")` so the Google title and description come from the file.
- **Next.js (App Router)** `app/layout.tsx`:
```tsx
import contentFile from "@/public/onesystem-content.json";
import { loadContent, type ContentFile } from "@/onesystem/content";
import { OneSystemContent } from "@/onesystem/react";
const file = contentFile as ContentFile;
export const revalidate = 30;
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const initial = await loadContent({ site: process.env.NEXT_PUBLIC_ONESYSTEM_SITE, file, init: { next: { revalidate: 30 } } });
return <html lang="en"><body><OneSystemContent site={process.env.NEXT_PUBLIC_ONESYSTEM_SITE} file={file} initial={initial}>{children}</OneSystemContent></body></html>;
}
```
Server components can call `loadContent(...)` the same way; Google titles go in `generateMetadata` from
`seo.<path>.title` / `seo.<path>.description`.
- Deploy as usual. Then in One System click **Check connection**.
## 5. When you change the site later
Add new items to `public/onesystem-content.json` and deploy. One System adds them; the owner's earlier edits stay.
Remove an item only when its element is removed from the site.
content.ts
The helper: loads the content from One System (falls back to the built-in copy). Save as onesystem/content.ts. Download
// One System content helper — v1 (https://copilot.onesystemcrm.com/developers)
//
// Copy this file into your project as onesystem/content.ts. It loads the site's editable content (texts, links,
// images, Google titles) from One System, so the business can change it by chat with a preview and Undo.
// If One System doesn't answer quickly, the copy built into your site (public/onesystem-content.json) is used,
// so pages never go blank. Works in the browser (React / Vite) and on the server (Next.js).
export type ContentItem = { value: string; label?: string; type?: string; page?: string };
export type ContentFile = { version: 1; pages?: { path: string; name: string }[]; fields: Record<string, ContentItem> };
export type Content = Record<string, string>;
export const ONESYSTEM_API = "https://copilot.onesystemcrm.com";
const SITE_ID = /^os_[a-z0-9]{10}$/;
const TOKEN = /^[A-Za-z0-9_-]{20,100}$/;
/** The values built into the site (the fallback). */
export function bundledValues(file: ContentFile): Content {
const out: Content = {};
for (const [key, item] of Object.entries(file.fields ?? {})) out[key] = typeof item?.value === "string" ? item.value : "";
return out;
}
/** The preview token in the address bar (?os_preview=…), only in the browser. */
export function previewToken(search?: string): string | null {
const s = search ?? (typeof window === "undefined" ? "" : window.location.search);
const t = new URLSearchParams(s).get("os_preview");
return t && TOKEN.test(t) ? t : null;
}
export type LoadOptions = {
/** The Site ID from One System (Settings → Website), e.g. import.meta.env.VITE_ONESYSTEM_SITE or process.env.NEXT_PUBLIC_ONESYSTEM_SITE */
site?: string;
/** Your public/onesystem-content.json (imported), used as the fallback and to know which items exist */
file: ContentFile;
/** A preview token: shows a change that is waiting for the owner's approval */
preview?: string | null;
/** How long to wait for One System before using the built-in copy (default 1500 ms) */
timeoutMs?: number;
/** Next.js: e.g. { next: { revalidate: 30 } } */
init?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
api?: string;
};
/** The live content (or the built-in copy when One System can't be reached). Never throws. */
export async function loadContent(opts: LoadOptions): Promise<Content> {
const fallback = bundledValues(opts.file);
if (!opts.site || !SITE_ID.test(opts.site)) return fallback;
const preview = opts.preview && TOKEN.test(opts.preview) ? opts.preview : null;
const url = `${(opts.api ?? ONESYSTEM_API).replace(/\/+$/, "")}/api/site-content/${opts.site}${preview ? `?preview=${encodeURIComponent(preview)}` : ""}`;
const ctl = typeof AbortController === "undefined" ? undefined : new AbortController();
const timer = setTimeout(() => ctl?.abort(), opts.timeoutMs ?? 1500);
try {
const init: RequestInit & { next?: unknown } = { ...opts.init, signal: ctl?.signal };
if (preview) { init.cache = "no-store"; delete init.next; }
const res = await fetch(url, init);
if (!res.ok) return fallback;
const body = (await res.json()) as { values?: Record<string, unknown> };
const live: Content = {};
for (const [key, value] of Object.entries(body?.values ?? {})) if (typeof value === "string" && key in fallback) live[key] = value;
return { ...fallback, ...live };
} catch {
return fallback;
} finally {
clearTimeout(timer);
}
}
react.tsx
React: <OneSystemContent>, useContent(), useSeo(). Save as onesystem/react.tsx. Download
"use client";
// One System content for React — v1 (https://copilot.onesystemcrm.com/developers)
//
// Copy this file into your project as onesystem/react.tsx (next to onesystem/content.ts).
// Wrap the app once in <OneSystemContent>, then read items with useContent("home.hero.title").
// Always show content as text ({title}), never with dangerouslySetInnerHTML.
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { loadContent, previewToken, type Content, type ContentFile } from "./content";
const ContentContext = createContext<Content>({});
type Props = {
/** The Site ID from One System */
site?: string;
/** public/onesystem-content.json, imported */
file: ContentFile;
/** Next.js: the content loaded on the server (no waiting in the browser) */
initial?: Content;
/** Vite/React: how long to wait for One System before showing the built-in copy (default 1500 ms) */
waitMs?: number;
children: ReactNode;
};
/**
* Vite / React: <OneSystemContent site={import.meta.env.VITE_ONESYSTEM_SITE} file={contentFile}><App /></OneSystemContent>
* Next.js: pass `initial` from a server component (see the README). Preview links (?os_preview=…) work in both.
*/
export function OneSystemContent({ site, file, initial, waitMs, children }: Props) {
const [values, setValues] = useState<Content | null>(initial ?? null);
useEffect(() => {
const preview = previewToken();
if (initial && !preview) return;
let live = true;
loadContent({ site, file, preview, timeoutMs: waitMs }).then((v) => { if (live) setValues(v); });
return () => { live = false; };
}, [site, file, initial, waitMs]);
// Vite: wait a moment (at most waitMs) instead of flashing the built-in text and then the owner's newer text.
if (!values) return null;
return <ContentContext.Provider value={values}>{children}</ContentContext.Provider>;
}
/** One item's current value, e.g. useContent("contact.phone"). "" when the item doesn't exist. */
export function useContent(key: string): string {
return useContext(ContentContext)[key] ?? "";
}
/** Every item (for lists you build from several keys). */
export function useAllContent(): Content {
return useContext(ContentContext);
}
/**
* Vite / React apps: sets the page's Google title and description from the items "seo.<path>.title" and
* "seo.<path>.description" (e.g. "seo./about.title"). Next.js: use generateMetadata instead (see the README).
*/
export function useSeo(path: string) {
const values = useContext(ContentContext);
useEffect(() => {
const title = values[`seo.${path}.title`];
const description = values[`seo.${path}.description`];
if (title) document.title = title;
if (description) {
let tag = document.querySelector('meta[name="description"]');
if (!tag) { tag = document.createElement("meta"); tag.setAttribute("name", "description"); document.head.appendChild(tag); }
tag.setAttribute("content", description);
}
}, [values, path]);
}
Example content file
An example content file to start from. Save as public/onesystem-content.json. Download
{
"version": 1,
"pages": [
{
"path": "/",
"name": "Home"
},
{
"path": "/about",
"name": "About"
}
],
"fields": {
"home.hero.title": {
"label": "Home — main heading",
"type": "text",
"page": "/",
"value": "Fresh bread every morning"
},
"home.hero.text": {
"label": "Home — text under the heading",
"type": "paragraph",
"page": "/",
"value": "Baked in Austin since 1998."
},
"home.hero.button": {
"label": "Home — button",
"type": "text",
"page": "/",
"value": "See our menu"
},
"home.hero.button_link": {
"label": "Home — button link",
"type": "link",
"page": "/",
"value": "/menu"
},
"home.hero.image": {
"label": "Home — main photo",
"type": "image",
"page": "/",
"value": "/images/hero.jpg"
},
"home.hero.image_alt": {
"label": "Home — main photo description",
"type": "text",
"page": "/",
"value": "Loaves on the counter"
},
"contact.phone": {
"label": "Phone number",
"type": "phone",
"page": "*",
"value": "+1 512 555 0142"
},
"contact.email": {
"label": "Email",
"type": "email",
"page": "*",
"value": "hello@example.com"
},
"seo./.title": {
"label": "Home — Google title",
"type": "seo_title",
"page": "/",
"value": "Fresh Bread in Austin | Example Bakery"
},
"seo./.description": {
"label": "Home — Google description",
"type": "seo_description",
"page": "/",
"value": "Sourdough, pastries and coffee, baked fresh every morning."
},
"about.title": {
"label": "About — main heading",
"type": "text",
"page": "/about",
"value": "Our story"
},
"footer.hours": {
"label": "Footer — opening hours",
"type": "paragraph",
"page": "*",
"value": "Mon–Fri 7am–6pm\nSat 8am–2pm"
}
}
}