English
React Router
examples/react-router is a React Router 8 app in framework mode, the successor to Remix and the base of Shopify's app template. It embeds the editor and backs it with resource routes: route modules with a loader or an action and no component. It stores templates and saved blocks as JSON files under ./data, renders HTML export and test emails on the server with @templatical/renderer and mjml, and writes each test email to ./data/outbox instead of sending it. CI builds the app and runs it in a browser against every change to the SDK.
Running the example
Or copy it into a new directory:
bash
npx degit templatical/sdk/examples/react-router my-app
cd my-app
npm install
npm run devThe editor component
The component mounts the editor in an effect. It imports init() inside the effect, so the editor never runs on the server, and its cleanup unmounts the editor, including one that finishes loading after React StrictMode has cleaned the effect up. Without ?id= in the URL, it creates a template and writes the new id into the URL with navigate from useNavigate. If opening the template fails, the toolbar shows the server's message, such as "Template not found.", with a link that starts a new template. The toolbar also shows errors the editor reports through onError, such as a saved-block library that fails to load, and a failed export; the next export clears them.
app/components/email-editor.tsx
tsx
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import type { TemplaticalEditor } from "@templatical/editor";
import "@templatical/editor/style.css";
import {
renderProvider,
savedBlocksProvider,
templatesProvider,
testEmailProvider,
} from "../lib/templatical/providers";
type Problem = { message: string; offerRestart: boolean };
// Every message ends as a sentence: the providers' fallback, such as
// "GET /api/templates/… failed (500)", has no full stop and would run into the
// restart link.
const messageOf = (error: unknown) => {
const text = (error instanceof Error ? error.message : String(error)).trim();
return /[.!?]$/.test(text) ? text : `${text}.`;
};
export function EmailEditor() {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<TemplaticalEditor | null>(null);
const [problem, setProblem] = useState<Problem | null>(null);
// navigate, not useSearchParams' setter: the setter changes with the URL, so
// depending on it re-runs this effect and mounts a second editor.
const navigate = useNavigate();
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let cancelled = false;
let instance: TemplaticalEditor | null = null;
(async () => {
// Imported inside the effect: the editor runs only in the browser.
const { init } = await import("@templatical/editor");
if (cancelled) return;
const ed = await init({
container,
templates: templatesProvider,
savedBlocks: savedBlocksProvider,
testEmail: testEmailProvider,
render: renderProvider,
onError: (error) => {
if (!cancelled) setProblem({ message: messageOf(error), offerRestart: false });
},
});
if (cancelled) {
ed.unmount();
return;
}
instance = ed;
editorRef.current = ed;
// ?id= picks the template; without one, create a template and put its
// id in the URL so a reload reopens the same template.
const id = new URLSearchParams(window.location.search).get("id");
if (id) {
await ed.load(id);
} else {
const template = await ed.create({ name: "Untitled" });
if (!cancelled) await navigate({ search: `?id=${template.id}` }, { replace: true });
}
})().catch((error: unknown) => {
// The providers throw the server's own message, such as "Template not found."
if (!cancelled) setProblem({ message: messageOf(error), offerRestart: true });
});
return () => {
cancelled = true;
instance?.unmount();
editorRef.current = null;
};
}, [navigate]);
async function exportHtml() {
// A new export replaces an earlier failure. A failed load keeps its message
// and restart link: the template it names never opened.
setProblem((current) => (current?.offerRestart ? current : null));
// Opened inside the click, so a popup blocker allows it, and filled once
// the HTML is ready.
const tab = window.open("", "_blank");
if (!tab) {
setProblem({ message: "Allow pop-ups for this page to see the export.", offerRestart: false });
return;
}
try {
const html = await editorRef.current?.toHtml();
if (!html) {
tab.close();
return;
}
// The HTML renders in a sandboxed frame: HTML blocks and rich text are
// author content, and in this tab they would run with the app's origin.
const frame = tab.document.createElement("iframe");
frame.setAttribute("sandbox", "");
frame.srcdoc = html;
frame.style.cssText = "border: 0; width: 100%; height: 100%";
tab.document.documentElement.style.height = "100%";
tab.document.body.style.cssText = "margin: 0; height: 100%";
tab.document.body.append(frame);
} catch (error) {
tab.close();
setProblem({ message: messageOf(error), offerRestart: false });
}
}
return (
<>
<div className="toolbar">
<button type="button" data-testid="export-html" onClick={exportHtml}>
Export HTML
</button>
{problem && (
<span role="alert">
{problem.message}
{problem.offerRestart && (
<>
{" "}
{/* A full page load, not a router <Link>: only a fresh mount creates the template. */}
<a href="/">Start a new template</a>.
</>
)}
</span>
)}
</div>
<div ref={containerRef} className="editor" />
</>
);
}The providers
The editor reaches the backend only through these objects. Each method is one fetch to a route below. The file is the same in the Next.js, Nuxt, SvelteKit and React Router examples.
app/lib/templatical/providers.ts
ts
// The editor reaches the backend only through these four objects. Each method
// is one fetch to an /api route of this app; point them at any backend that
// implements the same routes, in any language.
import type {
RenderProvider,
SavedBlocksListParams,
SavedBlocksProvider,
TemplatesProvider,
TestEmailProvider,
} from "@templatical/types";
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
// Every write declares JSON, the bodyless DELETE included: a framework's
// CSRF check (SvelteKit's, for one) treats a write without a content type
// as a form post and can reject it as cross-site.
headers: method === "GET" ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
const error = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(error?.message ?? `${method} ${path} failed (${response.status})`);
}
return (response.status === 204 ? undefined : await response.json()) as T;
}
function listQuery(params: SavedBlocksListParams = {}): string {
const query = new URLSearchParams();
if (params.search) query.set("search", params.search);
if (params.category) query.set("category", params.category);
const text = query.toString();
return text ? `?${text}` : "";
}
export const templatesProvider: TemplatesProvider = {
load: (id) => request("GET", `/api/templates/${encodeURIComponent(id)}`),
create: (input) => request("POST", "/api/templates", input),
save: (id, patch) => request("PATCH", `/api/templates/${encodeURIComponent(id)}`, patch),
autoSave: true,
};
export const savedBlocksProvider: SavedBlocksProvider = {
list: (params) => request("GET", `/api/saved-blocks${listQuery(params)}`),
create: (input) => request("POST", "/api/saved-blocks", input),
update: (id, patch) => request("PATCH", `/api/saved-blocks/${encodeURIComponent(id)}`, patch),
delete: (id) => request("DELETE", `/api/saved-blocks/${encodeURIComponent(id)}`),
};
export const testEmailProvider: TestEmailProvider = {
defaultRecipient: "[email protected]",
send: ({ recipient, content }) => request("POST", "/api/test-email", { recipient, content }),
};
export const renderProvider: RenderProvider = {
toMjml: async ({ content }) =>
(await request<{ mjml: string }>("POST", "/api/render", { content })).mjml,
toHtml: async ({ content }) =>
(await request<{ html: string }>("POST", "/api/render", { content })).html,
};The server routes
app/routes.ts registers each resource route. Server-only modules end in .server.ts, which keeps them out of the client bundle. Each route validates its input, calls the store or the renderer, and answers with JSON or an empty 204. A rejected request gets a 4xx status and { message }, which the editor reports.
app/routes.ts
ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("api/templates", "routes/api.templates.ts"),
route("api/templates/:id", "routes/api.templates.$id.ts"),
route("api/saved-blocks", "routes/api.saved-blocks.ts"),
route("api/saved-blocks/:id", "routes/api.saved-blocks.$id.ts"),
route("api/render", "routes/api.render.ts"),
route("api/test-email", "routes/api.test-email.ts"),
] satisfies RouteConfig;app/routes/api.templates.ts
ts
import type { ActionFunctionArgs } from "react-router";
import { createTemplate, templateInputError, type TemplateInput } from "../lib/templatical/store.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") return Response.json({ message: "Method not allowed." }, { status: 405 });
const body: unknown = await request.json().catch(() => null);
const problem = templateInputError(body, false);
if (problem) return Response.json({ message: problem }, { status: 400 });
return Response.json(await createTemplate(body as TemplateInput), { status: 201 });
}app/routes/api.templates.$id.ts
ts
import type { TemplatePatch } from "@templatical/types";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { getTemplate, templateInputError, updateTemplate } from "../lib/templatical/store.server";
const notFound = () => Response.json({ message: "Template not found." }, { status: 404 });
export async function loader({ params }: LoaderFunctionArgs) {
const template = await getTemplate(params.id ?? "");
return template ? Response.json(template) : notFound();
}
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method !== "PATCH") return Response.json({ message: "Method not allowed." }, { status: 405 });
const body: unknown = await request.json().catch(() => null);
const problem = templateInputError(body, true);
if (problem) return Response.json({ message: problem }, { status: 400 });
const template = await updateTemplate(params.id ?? "", body as TemplatePatch);
return template ? Response.json(template) : notFound();
}app/routes/api.saved-blocks.ts
ts
import type { SavedBlockInput } from "@templatical/types";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { createSavedBlock, listSavedBlocks, savedBlockInputError } from "../lib/templatical/store.server";
export async function loader({ request }: LoaderFunctionArgs) {
const query = new URL(request.url).searchParams;
return Response.json(
await listSavedBlocks({
search: query.get("search") ?? undefined,
category: query.get("category") ?? undefined,
}),
);
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") return Response.json({ message: "Method not allowed." }, { status: 405 });
const body: unknown = await request.json().catch(() => null);
const problem = savedBlockInputError(body, false);
if (problem) return Response.json({ message: problem }, { status: 400 });
return Response.json(await createSavedBlock(body as SavedBlockInput), { status: 201 });
}app/routes/api.saved-blocks.$id.ts
ts
import type { SavedBlockPatch } from "@templatical/types";
import type { ActionFunctionArgs } from "react-router";
import { deleteSavedBlock, savedBlockInputError, updateSavedBlock } from "../lib/templatical/store.server";
const notFound = () => Response.json({ message: "Saved block not found." }, { status: 404 });
export async function action({ request, params }: ActionFunctionArgs) {
const id = params.id ?? "";
if (request.method === "DELETE") {
return (await deleteSavedBlock(id)) ? new Response(null, { status: 204 }) : notFound();
}
if (request.method !== "PATCH") return Response.json({ message: "Method not allowed." }, { status: 405 });
const body: unknown = await request.json().catch(() => null);
const problem = savedBlockInputError(body, true);
if (problem) return Response.json({ message: problem }, { status: 400 });
const block = await updateSavedBlock(id, body as SavedBlockPatch);
return block ? Response.json(block) : notFound();
}app/routes/api.render.ts
ts
import { isRenderableTemplateContent } from "@templatical/types";
import type { ActionFunctionArgs } from "react-router";
import { renderTemplate } from "../lib/templatical/render.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") return Response.json({ message: "Method not allowed." }, { status: 405 });
const body = (await request.json().catch(() => null)) as { content?: unknown } | null;
if (!isRenderableTemplateContent(body?.content)) {
return Response.json({ message: "`content` must be a template with a `blocks` array." }, { status: 400 });
}
return Response.json(await renderTemplate(body.content));
}app/routes/api.test-email.ts
ts
import { isRenderableTemplateContent } from "@templatical/types";
import type { ActionFunctionArgs } from "react-router";
import { deliver, isEmailAddress } from "../lib/templatical/outbox.server";
import { renderTemplate } from "../lib/templatical/render.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") return Response.json({ message: "Method not allowed." }, { status: 405 });
const body = (await request.json().catch(() => null)) as { recipient?: unknown; content?: unknown } | null;
if (!isEmailAddress(body?.recipient)) {
return Response.json({ message: "Enter a valid email address." }, { status: 400 });
}
if (!isRenderableTemplateContent(body.content)) {
return Response.json({ message: "`content` must be a template with a `blocks` array." }, { status: 400 });
}
const { html } = await renderTemplate(body.content);
await deliver({ to: body.recipient, subject: "Test email", html });
return new Response(null, { status: 204 });
}The render and test-email routes compile the template with renderTemplate() from app/lib/templatical/render.server.ts:
ts
// Compiles a template to email HTML on the server: the renderer turns the
// editor's JSON into MJML, and the mjml package turns MJML into HTML.
import mjml2html from "mjml";
import { renderToMjml } from "@templatical/renderer";
import type { TemplateContent } from "@templatical/types";
export async function renderTemplate(content: TemplateContent): Promise<{ mjml: string; html: string }> {
const mjml = await renderToMjml(content);
const { html } = await mjml2html(mjml);
return { mjml, html };
}Moving to production
- Replace the function bodies in
app/lib/templatical/store.server.tswith calls to your database. Every route that loads or saves templates or saved blocks goes through them. - Replace
deliver()inapp/lib/templatical/outbox.server.tswith your email provider. It receives the recipient, the subject and the finished HTML. - Add authentication to the resource routes. They are open in this example.
- Sanitize HTML blocks on the server before you store or send them, or pass
allowHtmlBlocks: falsetorenderToMjmlinrender.server.ts: the editor keeps author HTML as written. - Before you add cookie-based authentication, reject writes that don't send
content-type: application/json, or add CSRF tokens: a cross-site page can send atext/plainbody without a preflight, and these routes read any body as JSON.
Connect your backend covers the providers this example leaves out: version history, comments and media.