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

# Nuxt

[`examples/nuxt`](https://github.com/templatical/sdk/tree/main/examples/nuxt) is a Nuxt 4 app that embeds the editor and backs it with its own server routes. 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

Copy it into a new directory:

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

## The editor component

A client-only component (`.client.vue`) mounts the editor in `onMounted` and unmounts it in `onBeforeUnmount`, including an editor that finishes loading after the component has gone. It waits one tick first, because Nuxt renders a client-only component's template after the component mounts. It imports `init()` inside `onMounted`, 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. 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/EmailEditor.client.vue`

```vue
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import type { TemplaticalEditor } from "@templatical/editor";
import "@templatical/editor/style.css";
import {
  renderProvider,
  savedBlocksProvider,
  templatesProvider,
  testEmailProvider,
} from "../utils/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}.`;
};

const container = ref<HTMLDivElement>();
const problem = ref<Problem | null>(null);
let editor: TemplaticalEditor | null = null;
let unmounted = false;

async function openEditor(el: HTMLDivElement) {
  // Imported here, not at the top: the editor runs only in the browser.
  const { init } = await import("@templatical/editor");
  if (unmounted) return;
  const instance = await init({
    container: el,
    templates: templatesProvider,
    savedBlocks: savedBlocksProvider,
    testEmail: testEmailProvider,
    render: renderProvider,
    onError: (error) => {
      if (!unmounted) problem.value = { message: messageOf(error), offerRestart: false };
    },
  });
  if (unmounted) {
    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 (!unmounted) window.history.replaceState(window.history.state, "", `?id=${template.id}`);
  }
}

onMounted(async () => {
  // Nuxt renders a .client.vue component's template one tick after the
  // component mounts, so the container does not exist until then.
  await nextTick();
  const el = container.value;
  if (!el) return;
  openEditor(el).catch((error: unknown) => {
    // The providers throw the server's own message, such as "Template not found."
    if (!unmounted) problem.value = { message: messageOf(error), offerRestart: true };
  });
});

onBeforeUnmount(() => {
  unmounted = 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.value?.offerRestart) problem.value = 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.value = { 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.value = { message: messageOf(error), offerRestart: false };
  }
}
</script>

<template>
  <div class="toolbar">
    <button type="button" data-testid="export-html" @click="exportHtml">Export HTML</button>
    <span v-if="problem" role="alert">
      {{ problem.message }}
      <template v-if="problem.offerRestart"><a href="/">Start a new template</a>.</template>
    </span>
  </div>
  <div ref="container" class="editor" />
</template>
```

## 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.

`app/utils/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

Nuxt maps each file under `server/api/` to a route, and the method in its name (`.get`, `.post`, …) to the HTTP method. 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.

`server/api/templates/index.post.ts`

```ts
import { createTemplate, templateInputError, type TemplateInput } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  const body: unknown = await readBody(event).catch(() => null);
  const problem = templateInputError(body, false);
  if (problem) {
    setResponseStatus(event, 400);
    return { message: problem };
  }
  setResponseStatus(event, 201);
  return createTemplate(body as TemplateInput);
});
```

`server/api/templates/[id].get.ts`

```ts
import { getTemplate } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  const template = await getTemplate(getRouterParam(event, "id") ?? "");
  if (!template) {
    setResponseStatus(event, 404);
    return { message: "Template not found." };
  }
  return template;
});
```

`server/api/templates/[id].patch.ts`

```ts
import type { TemplatePatch } from "@templatical/types";
import { templateInputError, updateTemplate } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  const body: unknown = await readBody(event).catch(() => null);
  const problem = templateInputError(body, true);
  if (problem) {
    setResponseStatus(event, 400);
    return { message: problem };
  }
  const template = await updateTemplate(getRouterParam(event, "id") ?? "", body as TemplatePatch);
  if (!template) {
    setResponseStatus(event, 404);
    return { message: "Template not found." };
  }
  return template;
});
```

`server/api/saved-blocks/index.get.ts`

```ts
import { listSavedBlocks } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  return listSavedBlocks({
    search: typeof query.search === "string" ? query.search : undefined,
    category: typeof query.category === "string" ? query.category : undefined,
  });
});
```

`server/api/saved-blocks/index.post.ts`

```ts
import type { SavedBlockInput } from "@templatical/types";
import { createSavedBlock, savedBlockInputError } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  const body: unknown = await readBody(event).catch(() => null);
  const problem = savedBlockInputError(body, false);
  if (problem) {
    setResponseStatus(event, 400);
    return { message: problem };
  }
  setResponseStatus(event, 201);
  return createSavedBlock(body as SavedBlockInput);
});
```

`server/api/saved-blocks/[id].patch.ts`

```ts
import type { SavedBlockPatch } from "@templatical/types";
import { savedBlockInputError, updateSavedBlock } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  const body: unknown = await readBody(event).catch(() => null);
  const problem = savedBlockInputError(body, true);
  if (problem) {
    setResponseStatus(event, 400);
    return { message: problem };
  }
  const block = await updateSavedBlock(getRouterParam(event, "id") ?? "", body as SavedBlockPatch);
  if (!block) {
    setResponseStatus(event, 404);
    return { message: "Saved block not found." };
  }
  return block;
});
```

`server/api/saved-blocks/[id].delete.ts`

```ts
import { deleteSavedBlock } from "../../utils/templatical/store";

export default defineEventHandler(async (event) => {
  if (await deleteSavedBlock(getRouterParam(event, "id") ?? "")) return sendNoContent(event, 204);
  setResponseStatus(event, 404);
  return { message: "Saved block not found." };
});
```

`server/api/render.post.ts`

```ts
import { isRenderableTemplateContent } from "@templatical/types";
import { renderTemplate } from "../utils/templatical/render";

export default defineEventHandler(async (event) => {
  const body = (await readBody(event).catch(() => null)) as { content?: unknown } | null;
  if (!isRenderableTemplateContent(body?.content)) {
    setResponseStatus(event, 400);
    return { message: "`content` must be a template with a `blocks` array." };
  }
  return renderTemplate(body.content);
});
```

`server/api/test-email.post.ts`

```ts
import { isRenderableTemplateContent } from "@templatical/types";
import { deliver, isEmailAddress } from "../utils/templatical/outbox";
import { renderTemplate } from "../utils/templatical/render";

export default defineEventHandler(async (event) => {
  const body = (await readBody(event).catch(() => null)) as { recipient?: unknown; content?: unknown } | null;
  if (!isEmailAddress(body?.recipient)) {
    setResponseStatus(event, 400);
    return { message: "Enter a valid email address." };
  }
  if (!isRenderableTemplateContent(body.content)) {
    setResponseStatus(event, 400);
    return { message: "`content` must be a template with a `blocks` array." };
  }
  const { html } = await renderTemplate(body.content);
  await deliver({ to: body.recipient, subject: "Test email", html });
  return sendNoContent(event, 204);
});
```

The render and test-email routes compile the template with `renderTemplate()` from `server/utils/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 [`server/utils/templatical/store.ts`](https://github.com/templatical/sdk/blob/main/examples/nuxt/server/utils/templatical/store.ts) with calls to your database. Every route that loads or saves templates or saved blocks goes through them.
- Replace `deliver()` in [`server/utils/templatical/outbox.ts`](https://github.com/templatical/sdk/blob/main/examples/nuxt/server/utils/templatical/outbox.ts) with your email provider. It receives the recipient, the subject and the finished HTML.
- Add authentication to the server routes. They are open in this example.
- 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.
- 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 a body with no `content-type`, or with `multipart/form-data`, without a preflight, and these routes parse either as JSON.

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