---
title: React Router
description: Eine lauffähige React-Router-8-App im Framework-Modus mit dem Templatical-Editor, angebunden über Resource Routes für Templates, gespeicherte Blöcke, HTML-Export und Test-E-Mails.
---

# React Router

[`examples/react-router`](https://github.com/templatical/sdk/tree/main/examples/react-router) ist eine React-Router-8-App im Framework-Modus, der Nachfolger von Remix und die Grundlage von Shopifys App-Template. Die App bettet den Editor ein und bindet ihn über Resource Routes an: Route-Module mit einem `loader` oder einer `action` und ohne Komponente. Die App speichert Templates und gespeicherte Blöcke als JSON-Dateien unter `./data`, rendert HTML-Export und Test-E-Mails auf dem Server mit `@templatical/renderer` und `mjml` und schreibt jede Test-E-Mail nach `./data/outbox`, statt sie zu versenden. CI baut die App und führt sie bei jeder Änderung am SDK in einem Browser aus.

## Ausführen des Beispiels

[In StackBlitz öffnen](https://stackblitz.com/github/templatical/sdk/tree/main/examples/react-router)

Oder kopieren Sie es in ein neues Verzeichnis:

```bash
npx degit templatical/sdk/examples/react-router my-app
cd my-app
npm install
npm run dev
```

## Die Editor-Komponente

Die Komponente bindet den Editor in einem Effect ein. Sie importiert `init()` innerhalb des Effects, daher läuft der Editor nie auf dem Server, und ihr Cleanup unmountet den Editor – auch einen, der erst fertig lädt, nachdem React StrictMode den Effect aufgeräumt hat. Ohne `?id=` in der URL legt sie ein Template an und schreibt die neue ID mit `navigate` aus `useNavigate` in die URL. Schlägt das Öffnen des Templates fehl, zeigt die Werkzeugleiste die Meldung des Servers an, etwa „Template not found.“, mit einem Link, der ein neues Template anlegt. Die Werkzeugleiste zeigt auch Fehler, die der Editor über `onError` meldet, etwa eine Bibliothek gespeicherter Blöcke, die nicht lädt, und einen fehlgeschlagenen Export; der nächste Export entfernt sie.

`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" />
    </>
  );
}
```

## Die Provider {#providers}

Der Editor erreicht das Backend nur über diese Objekte. Jede Methode ist ein `fetch` an eine der Routen unten. Die Datei ist in den Beispielen für Next.js, Nuxt, SvelteKit und React Router identisch.

`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: "you@example.com",
  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,
};
```

## Die Server-Routen

`app/routes.ts` registriert jede Resource Route. Module, die nur auf dem Server laufen, enden auf `.server.ts`, was sie aus dem Client-Bundle heraushält. Jede Route prüft ihre Eingaben, ruft den Store oder den Renderer auf und antwortet mit JSON oder einem leeren 204. Eine abgelehnte Anfrage erhält einen 4xx-Status und `{ message }`, die der Editor anzeigt.

`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 });
}
```

Die Render- und die Test-E-Mail-Route kompilieren das Template mit `renderTemplate()` aus `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 };
}
```

## Wechsel in die Produktion

- Ersetzen Sie die Funktionsrümpfe in [`app/lib/templatical/store.server.ts`](https://github.com/templatical/sdk/blob/main/examples/react-router/app/lib/templatical/store.server.ts) durch Aufrufe Ihrer Datenbank. Jede Route, die Templates oder gespeicherte Blöcke lädt oder speichert, nutzt diese Funktionen.
- Ersetzen Sie `deliver()` in [`app/lib/templatical/outbox.server.ts`](https://github.com/templatical/sdk/blob/main/examples/react-router/app/lib/templatical/outbox.server.ts) durch Ihren E-Mail-Anbieter. Die Funktion erhält Empfänger, Betreff und das fertige HTML.
- Sichern Sie die Resource Routes mit einer Authentifizierung ab. In diesem Beispiel sind sie offen.
- Bereinigen Sie HTML-Blöcke auf dem Server, bevor Sie sie speichern oder versenden, oder übergeben Sie in `render.server.ts` `allowHtmlBlocks: false` an `renderToMjml`: Der Editor speichert Autoren-HTML unverändert.
- Bevor Sie eine Cookie-basierte Authentifizierung hinzufügen, lehnen Sie Schreibzugriffe ohne `content-type: application/json` ab oder verwenden Sie CSRF-Tokens: Eine fremde Seite kann einen `text/plain`-Body ohne Preflight senden, und diese Routen lesen jeden Body als JSON.

[Backend anbinden](/de/backend/) behandelt die Provider, die dieses Beispiel auslässt: Versionsverlauf, Kommentare und Medien.
