---
title: Installation
description: Install the Templatical email editor via npm or CDN.
---

# Installation

::: tip Active development
Templatical is under active development and ships frequently. The public API is stabilizing — we follow [SemVer](https://semver.org), use [changesets](https://github.com/changesets/changesets) for every release, and document breaking changes in the [changelog](https://github.com/templatical/sdk/releases). Pin a version in production and watch [GitHub releases](https://github.com/templatical/sdk/releases) to stay current.

Have a feature request or hit a rough edge? [Open a discussion](https://github.com/templatical/sdk/discussions) — feedback shapes the roadmap.
:::

## Requirements

- **Modern browser** -- support depends on which mount mode you use:
  - **Default mode** (`shadowDom: true`, Shadow DOM) — Chrome 80+, Edge 80+, Firefox 101+, Safari 16.4+. Firefox and Safari minimums are driven by the `adoptedStyleSheets` API the shadow path relies on.
  - **Opt-out mode** (`shadowDom: false`, light DOM) — Chrome 80+, Edge 80+, Firefox 80+, Safari 14+. Use this if you need to support older Firefox or Safari, or if your integration requires light-DOM access to editor internals. See the [Shadow DOM guide](../guide/shadow-dom) for trade-offs.
- **Container element** -- must have a defined height (the editor fills its container). In default mode, must be an element type that can host a shadow root (e.g. `<div>`, `<section>`, `<article>`). See [container element requirements](../api/editor#container-element-requirements).
- **No `transform`, and no stacking context, on an ancestor of the container** -- `transform`, `filter`, `perspective`, `will-change`, `opacity` below `1`, `isolation`, `contain`, and positioned elements with a `z-index` each change where the editor's overlays are painted or positioned. These are plain CSS rules, not Templatical-specific limitations, and they affect any library that positions overlays with `position: fixed`. See [Embedding the editor](./embedding) for what each one breaks and how to work around it.
- **No required peer dependencies** -- Vue, TipTap, and all internal libraries are bundled into the editor. You don't need to install Vue or any framework runtime, regardless of which framework your app uses. (`@templatical/renderer`, `@templatical/quality`, and `pusher-js` are _optional_ peers — install them only if you use the corresponding feature; see [Optional peers](#optional-peers) below.)

## Network requests

The editor makes **no** requests to Templatical. There is no license key, no client ID, no activation call, no entitlement check, and no telemetry. Nothing about the editor is enabled or disabled remotely, so an installed copy keeps working indefinitely.

It does make exactly one third-party request, and you should know about it before you deploy:

| Request | Made by | When |
| ------- | ------- | ---- |
| `https://fonts.bunny.net/css?family=geist:400,500,600` | A CSS `@import` at the top of the editor stylesheet | Whenever the stylesheet is parsed, in both DOM modes |

Geist is the editor's default UI font. If the request to load it is blocked or fails, the editor works normally — text falls back to the next family in the stack. Two cases where you might notice:

- **Strict Content Security Policy** — a policy such as `style-src 'self'` blocks the `@import`. Add `https://fonts.bunny.net` to `style-src` and `font-src`, or accept the fallback font.
- **Air-gapped or offline deployments** — the request fails and the fallback font is used.

To stop the editor depending on Geist, override the font token:

```css
.tpl,
#your-editor-container {
  --tpl-user-font-family: system-ui, sans-serif;
}
```

The `@import` is still present in the stylesheet, so the request is still attempted. To remove it outright, self-host Geist and strip the `@import` from your copy of `dist/style.css` as a build step. See [Theming](../guide/theming) for the full font token surface.

## The editor's container

The container you pass to `init()` has a few CSS constraints, and an ancestor with `transform`, `overflow: hidden`, or its own stacking context can misplace or clip the editor's dialogs.

See [Embedding the editor](./embedding) for what each property breaks and how to work around it.

## npm

::: code-group

```bash [npm]
npm install @templatical/editor
```

```bash [pnpm]
pnpm add @templatical/editor
```

```bash [yarn]
yarn add @templatical/editor
```

```bash [bun]
bun add @templatical/editor
```

:::

`@templatical/editor` is the visual editor. To convert templates to MJML, also install `@templatical/renderer`:

::: code-group

```bash [npm]
npm install @templatical/renderer
```

```bash [pnpm]
pnpm add @templatical/renderer
```

```bash [yarn]
yarn add @templatical/renderer
```

```bash [bun]
bun add @templatical/renderer
```

:::

The renderer is **optional**. Install it where you need MJML output:

- **Browser, with the editor** — when calling `editor.toMjml()` to export from the user's session.
- **Node.js / server** — when you only have stored template JSON and want to convert it to MJML server-side. You don't need the editor for this; install just the renderer.

If you call `editor.toMjml()` without the renderer installed, it throws a clear error naming the missing package.

## Package overview

| Package                        | Description                                                                                                                              | When to install                                                                                     |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `@templatical/editor`          | Visual drag-and-drop editor and `init()` entry point. Self-contained — Vue, TipTap, and `@templatical/core`/`/types` are bundled inside. | Required                                                                                            |
| `@templatical/renderer`        | Converts templates to MJML for email sending.                                                                                            | Optional — install where you call `editor.toMjml()` (browser) or `renderToMjml()` (Node.js, server) |
| `@templatical/quality`         | Template linters (accessibility, structure, links) that drive the editor's Issues panel and a headless / CI check.                             | Optional — install to turn on the Issues sidebar tab and inline block badges                        |
| `@templatical/media-library`   | Standalone media SDK (`init()`, `useMediaLibrary`, `MediaLibraryModal` in a Vue app). The editor's Browse UI is bundled into `@templatical/editor`. | Optional — only for standalone use, not for `init({ media })` or Cloud's store |
| `@templatical/types`           | Shared TypeScript types, block factory functions, type guards.                                                                           | Only if you build templates programmatically without the editor (e.g. server-side workflows)        |
| `@templatical/core`            | Framework-agnostic editor logic (state, history) for headless setups.                                                                    | Only for headless / non-editor consumers                                                            |
| `@templatical/template-tools`  | CLI and library for validating, rendering, editing, importing, and live-previewing templates outside the editor.                        | Optional — run via `npx`, nothing to install; for CI checks, scripts, or generating templates without a mounted editor |
| `@templatical/import-beefree`  | Converts BeeFree JSON templates to Templatical format.                                                                                   | Optional                                                                                            |
| `@templatical/import-unlayer`  | Converts Unlayer JSON design templates to Templatical format.                                                                            | Optional                                                                                            |
| `@templatical/import-html`     | Converts existing HTML email templates (table-based) to Templatical format.                                                              | Optional                                                                                            |
| `@templatical/import-mjml`     | Converts MJML email templates to Templatical format.                                                                                     | Optional                                                                                            |
| `@templatical/import-topol`    | Converts Topol JSON templates to Templatical format.                                                                                     | Optional                                                                                            |
| `@templatical/import-stripo`   | Converts Stripo plugin HTML and compiled File→HTML exports to Templatical format.                                                        | Optional                                                                                            |
| `@templatical/import-chamaileon` | Converts Chamaileon `getDocument()` JSON to Templatical format.                                                                        | Optional                                                                                            |
| `@templatical/import-easy-email-pro` | Converts Easy Email Pro persist JSON to Templatical format.                                                                        | Optional                                                                                            |

`@templatical/editor` ships as a single self-contained ESM bundle: every runtime dependency it needs (Vue, TipTap, vue-draggable-plus, `@templatical/core`, `@templatical/types`, etc.) is inlined. You never install them separately — and you never get duplicate copies in your app's `node_modules`.

## Optional peers

The editor lazy-loads three optional peers via dynamic `import()` at runtime, gated by feature use:

| Peer                         | When loaded                     | Install if you                    |
| ---------------------------- | ------------------------------- | --------------------------------- |
| `@templatical/renderer`      | First call to `editor.toMjml()` | Need MJML export from the browser |
| `@templatical/quality`       | Editor mount (Issues panel)     | Want accessibility, structure, and link lint in the Issues sidebar |
| `pusher-js`                  | Cloud realtime connect          | Use `initCloud()`                 |

If you don't install them, the editor still mounts. Quality's Issues tab and Pusher stay off. `editor.toMjml()` throws a clear error naming the missing package. Browse needs no extra package — the modal is a lazy chunk of the editor.

### A note on bundler output

The editor works out of the box with every modern bundler — no consumer configuration is required regardless of which optional peers you install. Vite, esbuild, Rollup, and Rolldown handle the optional dynamic imports silently. Webpack 5 is slightly more verbose: it statically analyzes every `import()` and prints a harmless `Module not found` **warning** for each uninstalled optional peer. The build still succeeds and the editor runs correctly — these warnings are cosmetic only.

If you'd prefer a clean Webpack log, you can opt into silencing them with `ignoreWarnings`:

```js
// webpack.config.js — optional, only if the warnings bother you
module.exports = {
  ignoreWarnings: [
    {
      module: /@templatical[\\/]editor/,
      message:
        /Can't resolve '(pusher-js|@templatical\/(quality|renderer))'/,
    },
  ],
};
```

## Framework integration

Templatical mounts into any DOM element. It creates its own isolated application internally, so it works with any framework — or no framework at all.

::: code-group

```ts [Vanilla JS]
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";

const editor = await init({
  container: "#editor",
  onChange(content) {
    console.log("Content changed", content);
  },
});

// Later, when removing the editor:
editor.unmount();
```

```tsx [React]
import { useRef, useEffect } from "react";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";

export function EmailEditor() {
  const containerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<TemplaticalEditor | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    let cancelled = false;
    let instance: TemplaticalEditor | null = null;

    (async () => {
      const ed = await init({
        container: containerRef.current,
        onChange(content) {
          console.log("Content changed", content);
        },
      });
      if (cancelled) {
        ed.unmount();
        return;
      }
      instance = ed;
      editorRef.current = ed;
    })();

    return () => {
      cancelled = true;
      instance?.unmount();
      editorRef.current = null;
    };
  }, []);

  return <div ref={containerRef} style={{ height: "100vh" }} />;
}
```

```vue [Vue]
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";

const container = ref<HTMLElement>();
let editor: TemplaticalEditor | null = null;

onMounted(async () => {
  if (!container.value) return;

  editor = await init({
    container: container.value,
    onChange(content) {
      console.log("Content changed", content);
    },
  });
});

onUnmounted(() => {
  editor?.unmount();
});
</script>

<template>
  <div ref="container" style="height: 100vh" />
</template>
```

```svelte [Svelte]
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { init } from '@templatical/editor';
  import '@templatical/editor/style.css';
  import type { TemplaticalEditor } from '@templatical/editor';

  let containerEl: HTMLElement;
  let editor: TemplaticalEditor | null = null;

  onMount(async () => {
    editor = await init({
      container: containerEl,
      onChange(content) {
        console.log('Content changed', content);
      },
    });
  });

  onDestroy(() => {
    editor?.unmount();
  });
</script>

<div bind:this={containerEl} style="height: 100vh;" />
```

```ts [Angular]
import {
  Component,
  ElementRef,
  OnDestroy,
  OnInit,
  ViewChild,
} from "@angular/core";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";

@Component({
  selector: "app-email-editor",
  standalone: true,
  template: `<div #editorContainer style="height: 100vh"></div>`,
})
export class EmailEditorComponent implements OnInit, OnDestroy {
  @ViewChild("editorContainer", { static: true })
  containerRef!: ElementRef<HTMLElement>;

  private editor: TemplaticalEditor | null = null;

  async ngOnInit(): Promise<void> {
    this.editor = await init({
      container: this.containerRef.nativeElement,
      onChange(content) {
        console.log("Content changed", content);
      },
    });
  }

  ngOnDestroy(): void {
    this.editor?.unmount();
  }
}
```

:::

If the effect re-runs before `init()` resolves — React StrictMode does this in development — unmount the instance that just finished, not only the ref.

::: warning Important
Always call `unmount()` when removing the editor from the page. This cleans up event listeners, timers, and DOM elements. This is especially important in single-page applications where components mount and unmount during navigation.
:::

## TypeScript support

All packages ship with full TypeScript type definitions. Configuration options, callback payloads, block types, and instance methods are fully typed:

```ts
import { init, unmount } from "@templatical/editor";
import type {
  TemplaticalEditor,
  TemplaticalEditorConfig,
} from "@templatical/editor";
import type {
  TemplateContent,
  Block,
  ThemeOverrides,
  FontsConfig,
} from "@templatical/types";
```

## Release tarballs

Every GitHub release carries the same tarballs that go to npm, one per package. Install from those when a build can't reach the registry, or when your dependencies have to come from URLs you vet yourself.

```json
{
  "dependencies": {
    "@templatical/renderer": "https://github.com/templatical/sdk/releases/download/v<version>/templatical-renderer-<version>.tgz"
  }
}
```

`<version>` is the package version and the tag is the same with a `v` in front — every published version has one on the [releases page](https://github.com/templatical/sdk/releases). The file is the one npm would have served you, so nothing about the package behaves differently.

Three things to know:

**Pin the Templatical packages you depend on indirectly, too.** A tarball refers to its siblings by version number, so your package manager still goes looking for that version on the registry. `@templatical/core`, `@templatical/quality`, `@templatical/renderer`, `@templatical/media-library` and the importers all depend on `@templatical/types`. Point each one you pull in at a tarball:

```yaml
# pnpm-workspace.yaml
overrides:
  '@templatical/types': https://github.com/templatical/sdk/releases/download/v<version>/templatical-types-<version>.tgz
```

npm and Yarn do the same thing with `overrides` and `resolutions` in `package.json`. `@templatical/editor` needs none of this — it bundles everything it uses.

**Third-party dependencies still come from a registry.** `@templatical/types`, `@templatical/renderer`, `@templatical/import-beefree`, `@templatical/import-unlayer`, `@templatical/import-topol`, `@templatical/import-chamaileon` and `@templatical/import-easy-email-pro` install with nothing else at runtime. The rest pull packages that aren't ours: `@templatical/core` needs `@vue/reactivity`, `@templatical/quality` needs `htmlparser2`, `@templatical/import-html`, `@templatical/import-mjml` and `@templatical/import-stripo` need `cheerio` and `domhandler`, and `@templatical/media-library` needs `@lucide/vue`, `@vueuse/core` and `vue-advanced-cropper`. Installing those without a registry needs a mirror for them too.

**The source archives on that page are not a substitute.** "Source code (zip)" and "Source code (tar.gz)" are snapshots of the repository, as is a `github:templatical/sdk` dependency. Neither contains a built `dist/`, and both refer to sibling packages as `workspace:*`, which resolves to nothing outside this repo.

## CDN

If you prefer not to use a package manager, load the editor directly via script tags:

```html
<link
  rel="stylesheet"
  href="https://unpkg.com/@templatical/editor/dist/cdn/editor.css"
/>
<script type="module">
  import { init } from "https://unpkg.com/@templatical/editor/dist/cdn/editor.js";

  const editor = await init({
    container: "#editor",
  });
</script>

<div id="editor" style="height: 100vh;"></div>
```

The CDN build is fully self-contained — all dependencies are bundled. Heavy libraries (TipTap, Vue, Pusher, etc.) are code-split into separate chunks and loaded on demand.

### Pinning a version

The example above is unversioned on purpose: unpkg resolves a URL with no version segment to the latest published release on every request, which is convenient for trying the editor but means the exact code your page loads can change with no corresponding change on your end. Pin an exact version for production, loading it from jsDelivr rather than unpkg:

```html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@templatical/editor@<version>/dist/cdn/editor.css"
/>
<script type="module">
  import { init } from "https://cdn.jsdelivr.net/npm/@templatical/editor@<version>/dist/cdn/editor.js";

  const editor = await init({
    container: "#editor",
  });
</script>
```

**The host changes along with the URL shape, not just the version number.** The CDN build code-splits into hashed chunk files that the entry loads on demand, so the entry and every chunk it pulls in must resolve to the same published version — unpkg's redirect from an unversioned URL keeps that consistent, but requesting an already-pinned version's chunks from unpkg has intermittently served them with the wrong content type and a failing CORS preflight. jsDelivr serves a pinned version's chunks reliably, so keep pinned URLs there and the unversioned form on unpkg.
