@mapled/react
React helpers for Mapled, the hosted headless CMS for sites built with AI: published content in a React single-page app — Vite, Create React App, anything that renders in the browser. One provider, three hooks. Built on @mapled/client; a Next.js site uses @mapled/next instead.
Install
npm install @mapled/react
@mapled/client comes with it. Put the project's delivery key (Mapled: Integrations → Your site) in .env:
VITE_MAPLED_KEY=mk_live_…
It is bundled into the page, which is fine for this key and for no other: it reads published content only. A server key or a webhook signing secret never goes under a VITE_ name.
Wire it up
import { createClient } from "@mapled/client";
import { typedHooks } from "@mapled/react";
import type { MapledSchema } from "./mapled-types";
export const mapled = createClient<MapledSchema>({ key: import.meta.env.VITE_MAPLED_KEY });
export const { useSingle, useRecords, useRecord } = typedHooks<MapledSchema>();
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MapledProvider } from "@mapled/react";
import { mapled } from "./mapled";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MapledProvider client={mapled}>
<App />
</MapledProvider>
</StrictMode>,
);
mapled-types.ts is written by npx @mapled/cli types generate; with typedHooks<MapledSchema>() keys are checked and data has each collection's own type. Without types, import useSingle, useRecords and useRecord straight from @mapled/react.
Read content
Every hook answers { data, loading, error, refetch }.
import Markdown from "react-markdown";
import { assetUrl } from "@mapled/client";
import { useSingle } from "../mapled";
export function Home() {
const { data: home, loading, error } = useSingle("homepage");
if (loading) return <p>Loading…</p>;
if (error || !home) return <p>Nothing here yet.</p>;
return (
<main>
<h1>{home.data.headline}</h1>
{home.data.hero ? <img src={assetUrl(home.data.hero, { width: 1200 })} alt="" /> : null}
<Markdown>{home.data.intro ?? ""}</Markdown>
</main>
);
}
import { Link } from "react-router-dom";
import { useRecords } from "../mapled";
export function Blog() {
const { data, loading } = useRecords("posts", { sort: "-date", limit: 20, fields: ["title", "slug", "date"] });
if (loading && !data) return <p>Loading…</p>;
return (
<ul>
{data?.records.map((post) => (
<li key={post.id}>
<Link to={`/blog/${post.data.slug}`}>{post.data.title}</Link>
</li>
))}
</ul>
);
}
import Markdown from "react-markdown";
import { useParams } from "react-router-dom";
import { useRecord } from "../mapled";
export function Post() {
const { slug } = useParams();
const { data: post, loading } = useRecord("posts", slug ? { slug } : null);
if (loading) return <p>Loading…</p>;
if (!post) return <p>This post doesn't exist.</p>;
return (
<article>
<h1>{post.data.title}</h1>
<Markdown>{post.data.body}</Markdown>
</article>
);
}
useSingle(key, options?) — data is the single's record, or null while it is empty.
useRecords(collection, options?) — data is { records, total, release }; options are the client's: filter, sort, limit, offset, fields, expand, locale (read in one of the project's languages; createClient({ key, locale }) for every read).
useRecord(collection, { slug } | { id } | null, options?) — data is null when the record isn't published; null instead of a reference reads nothing (a router param that hasn't arrived).
- Components that ask for the same thing share one request; an inline options object is the same read on every render.
- Rich text values are Markdown — render them with a Markdown component, never as raw HTML.
useMapled() hands out the client for a read the hooks don't cover.
Preview
Nothing to add: when someone opens Preview in Mapled, the page opens with a one-time token in its address, the client trades it for a draft session, and the hooks show drafts in that tab. Say so on the page:
import { usePreview } from "@mapled/react";
export function PreviewBar() {
const preview = usePreview();
if (!preview.active) return null;
return (
<p role="status">
You are looking at a preview. <button onClick={preview.exit}>Back to the published site</button>
</p>
);
}
Mapled has to know where the site lives and that it renders in the browser: the site manifest's framework is "react-spa" (npx @mapled/cli scan --write, bindings push — or your AI agent's push_site_manifest), and Production domain in Project settings holds the site's public address (an agent sets it with set_site_url). There is no webhook to configure and no route to mount.
Live updates
A publish shows when the page is loaded again. To follow it while the page is open:
import { MapledProvider } from "@mapled/react";
import { mapled } from "./mapled";
import { App } from "./App";
export function LiveApp() {
return (
<MapledProvider client={mapled} live>
<App />
</MapledProvider>
);
}
Each open tab then asks Mapled for the release pointer once a minute (live={{ interval: 30 }} for thirty seconds, never under fifteen) while it is visible, and the hooks read again — pinned to the new release — when it moves; what was on screen stays there until the new answer arrives. The watch starts from the release the hooks' reads answered, so a publish that landed between them and the first check, or while a tab opened in the background waited to be shown, is heard at once. It is off by default because every check is a request of the delivery key's budget (1,200 a minute).
Forms
submitForm from @mapled/client posts a visitor's submission:
import { useState, type FormEvent } from "react";
import { submitForm } from "@mapled/client";
const CONSENT = "I agree to be contacted about my request.";
export function ContactForm() {
const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">("idle");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setState("sending");
const answer = await submitForm("contact", form, {
key: import.meta.env.VITE_MAPLED_KEY,
consent: new FormData(form).get("consent") ? CONSENT : undefined,
});
setState(answer.ok ? "sent" : "failed");
}
if (state === "sent") return <p>Thank you — we'll be in touch.</p>;
return (
<form onSubmit={submit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input name="_gotcha" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ display: "none" }} />
<label>
<input name="consent" type="checkbox" required /> {CONSENT}
</label>
<button disabled={state === "sending"}>Send</button>
{state === "failed" ? <p>That didn't go through. Try again in a minute.</p> : null}
</form>
);
}
Check the integration
npx @mapled/cli doctor
doctor knows a single-page app: it wants VITE_MAPLED_KEY and no webhook secret, reads ○ «Not needed» for the webhook and the routes, and fails an env file that puts a server key or a signing secret under a public prefix.
API
MapledProvider | client, live? (true or { interval }) |
useSingle, useRecords, useRecord | { data, loading, error, refetch } |
typedHooks<MapledSchema>() | The same three hooks, typed by the generated schema |
usePreview() | { active, exit } |
useMapled() | The provider's client |