---
title: SvelteKit
description: A runnable SvelteKit 3 app with the Templatical editor, backed by +server.ts routes for templates, saved blocks, HTML export and test emails.
---

# SvelteKit

[`examples/sveltekit`](https://github.com/templatical/sdk/tree/main/examples/sveltekit) is a SvelteKit 3 app (Svelte 5, adapter-node) that embeds the editor and backs it with its own `+server.ts` routes. SvelteKit 3 reads its configuration from `vite.config.ts`, and the example imports from `src/lib` through the `#lib/*` subpath import that `package.json` declares. 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

[Open in StackBlitz](https://stackblitz.com/github/templatical/sdk/tree/main/examples/sveltekit)

Or copy it into a new directory:

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

## The editor component

The component mounts the editor in `onMount` and returns the cleanup that unmounts it, including an editor that finishes loading after the component has gone. It imports `init()` inside `onMount`, so the editor never runs on the server. Without `?id=` in the URL, it creates a template and writes the new id into the URL with SvelteKit's `goto`. 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 restart link carries `data-sveltekit-reload`, which makes it a full page load, so the component mounts again and creates a 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.

`src/lib/EmailEditor.svelte`

```svelte
<script lang="ts">
  import { onMount } from "svelte";
  import { goto } from "$app/navigation";
  import type { TemplaticalEditor } from "@templatical/editor";
  import "@templatical/editor/style.css";
  import {
    renderProvider,
    savedBlocksProvider,
    templatesProvider,
    testEmailProvider,
  } from "#lib/templatical/providers.ts";

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

  let container: HTMLDivElement;
  let editor: TemplaticalEditor | null = null;
  let problem = $state<Problem | null>(null);

  onMount(() => {
    let cancelled = false;

    (async () => {
      // Imported here, not at the top: the editor runs only in the browser.
      const { init } = await import("@templatical/editor");
      if (cancelled) return;
      const instance = await init({
        container,
        templates: templatesProvider,
        savedBlocks: savedBlocksProvider,
        testEmail: testEmailProvider,
        render: renderProvider,
        onError: (error) => {
          if (!cancelled) problem = { message: messageOf(error), offerRestart: false };
        },
      });
      if (cancelled) {
        instance.unmount();
        return;
      }
      editor = instance;

      // ?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 instance.load(id);
      } else {
        const template = await instance.create({ name: "Untitled" });
        if (!cancelled) await goto(`?id=${template.id}`, { replace: true, shallow: true });
      }
    })().catch((error: unknown) => {
      // The providers throw the server's own message, such as "Template not found."
      if (!cancelled) problem = { message: messageOf(error), offerRestart: true };
    });

    return () => {
      cancelled = true;
      editor?.unmount();
      editor = null;
    };
  });

  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.
    if (!problem?.offerRestart) problem = 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) {
      problem = { message: "Allow pop-ups for this page to see the export.", offerRestart: false };
      return;
    }
    try {
      const html = await editor?.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();
      problem = { message: messageOf(error), offerRestart: false };
    }
  }
</script>

<div class="toolbar">
  <button type="button" data-testid="export-html" onclick={exportHtml}>Export HTML</button>
  {#if problem}
    <!-- data-sveltekit-reload makes the restart a full page load: a client-side navigation to "/" keeps this component mounted, so onMount would not run and no template would be created. -->
    <span role="alert">
      {problem.message}
      {#if problem.offerRestart}<a href="/" data-sveltekit-reload>Start a new template</a>.{/if}
    </span>
  {/if}
</div>
<div bind:this={container} class="editor"></div>
```

## The providers {#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.

`src/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,
};
```

## The server routes

Each `+server.ts` file exports one handler per HTTP method. Each handler 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. The handlers read request bodies with `readJson`, which answers a body over adapter-node's `BODY_SIZE_LIMIT` with a 413 that names the setting.

`src/lib/server/read-json.ts`

```ts
import { error } from "@sveltejs/kit";

/**
 * The request body as JSON, or null when it is not JSON. adapter-node fails the
 * read of a body larger than BODY_SIZE_LIMIT (512K by default), and that
 * answers 413 with a message naming the setting, not a 400 blaming the body.
 */
export async function readJson(request: Request): Promise<unknown> {
  try {
    return await request.json();
  } catch (cause) {
    if (typeof cause === "object" && cause !== null && "status" in cause && cause.status === 413) {
      error(413, "The request body is larger than BODY_SIZE_LIMIT allows (512K by default).");
    }
    return null;
  }
}
```

`src/routes/api/templates/+server.ts`

```ts
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { createTemplate, templateInputError, type TemplateInput } from "#lib/server/templatical/store.ts";

export const POST: RequestHandler = async ({ request }) => {
  const body: unknown = await readJson(request);
  const problem = templateInputError(body, false);
  if (problem) return Response.json({ message: problem }, { status: 400 });
  return Response.json(await createTemplate(body as TemplateInput), { status: 201 });
};
```

`src/routes/api/templates/[id]/+server.ts`

```ts
import type { TemplatePatch } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { getTemplate, templateInputError, updateTemplate } from "#lib/server/templatical/store.ts";

const notFound = () => Response.json({ message: "Template not found." }, { status: 404 });

export const GET: RequestHandler = async ({ params }) => {
  const template = await getTemplate(params.id);
  return template ? Response.json(template) : notFound();
};

export const PATCH: RequestHandler = async ({ params, request }) => {
  const body: unknown = await readJson(request);
  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();
};
```

`src/routes/api/saved-blocks/+server.ts`

```ts
import type { SavedBlockInput } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { createSavedBlock, listSavedBlocks, savedBlockInputError } from "#lib/server/templatical/store.ts";

export const GET: RequestHandler = async ({ url }) =>
  Response.json(
    await listSavedBlocks({
      search: url.searchParams.get("search") ?? undefined,
      category: url.searchParams.get("category") ?? undefined,
    }),
  );

export const POST: RequestHandler = async ({ request }) => {
  const body: unknown = await readJson(request);
  const problem = savedBlockInputError(body, false);
  if (problem) return Response.json({ message: problem }, { status: 400 });
  return Response.json(await createSavedBlock(body as SavedBlockInput), { status: 201 });
};
```

`src/routes/api/saved-blocks/[id]/+server.ts`

```ts
import type { SavedBlockPatch } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { deleteSavedBlock, savedBlockInputError, updateSavedBlock } from "#lib/server/templatical/store.ts";

const notFound = () => Response.json({ message: "Saved block not found." }, { status: 404 });

export const PATCH: RequestHandler = async ({ params, request }) => {
  const body: unknown = await readJson(request);
  const problem = savedBlockInputError(body, true);
  if (problem) return Response.json({ message: problem }, { status: 400 });
  const block = await updateSavedBlock(params.id, body as SavedBlockPatch);
  return block ? Response.json(block) : notFound();
};

export const DELETE: RequestHandler = async ({ params }) =>
  (await deleteSavedBlock(params.id)) ? new Response(null, { status: 204 }) : notFound();
```

`src/routes/api/render/+server.ts`

```ts
import { isRenderableTemplateContent } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { renderTemplate } from "#lib/server/templatical/render.ts";

export const POST: RequestHandler = async ({ request }) => {
  const body = (await readJson(request)) 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));
};
```

`src/routes/api/test-email/+server.ts`

```ts
import { isRenderableTemplateContent } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { deliver, isEmailAddress } from "#lib/server/templatical/outbox.ts";
import { renderTemplate } from "#lib/server/templatical/render.ts";

export const POST: RequestHandler = async ({ request }) => {
  const body = (await readJson(request)) 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 `src/lib/server/templatical/render.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 [`src/lib/server/templatical/store.ts`](https://github.com/templatical/sdk/blob/main/examples/sveltekit/src/lib/server/templatical/store.ts) with calls to your database. Every route that loads or saves templates or saved blocks goes through them.
- Replace `deliver()` in [`src/lib/server/templatical/outbox.ts`](https://github.com/templatical/sdk/blob/main/examples/sveltekit/src/lib/server/templatical/outbox.ts) with your email provider. It receives the recipient, the subject and the finished HTML.
- Add authentication to the `+server.ts` routes. They are open in this example.
- Keep the `content-type: application/json` header that `providers.ts` sends on every write. SvelteKit rejects a write with no content type as a cross-site form post when the browser's origin differs from the one adapter-node assumes, which is `https://` unless `PROTOCOL_HEADER` is set.
- Sanitize HTML blocks on the server before you store or send them, or pass `allowHtmlBlocks: false` to `renderToMjml` in `render.ts`: the editor keeps author HTML as written.
- adapter-node rejects request bodies over 512 KB by default. Set `BODY_SIZE_LIMIT` (for example `BODY_SIZE_LIMIT=10M`) if your templates are larger.

[Connect your backend](/backend/) covers the providers this example leaves out: version history, comments and media.
