Authoring in TSX

Create a template project, define a template with a schema and fixtures, preview and check it locally, then publish an exact commit.

A template project is a Git repository. Every .tsx file under templates/ that default-exports defineTemplate is one template; emails/ is also recognized, so a project can keep its templates in either folder. Helper files such as partials are ordinary project files and are not templates.

Only @samva/markup and files inside the project can be imported, so a project has no other dependency to install. In the project's tsconfig.json, set jsx: "react-jsx" and jsxImportSource: "@samva/markup/email".

Start a project

samva templates init --name "Order shipped"
cd order-shipped
samva templates dev

init creates the project on Samva and clones it. To begin from a gallery starter instead of the canonical one, pass its id with --starter:

samva templates init --name "Order shipped" --starter <starter-id>

An id that is not in the gallery fails and lists the valid ids. The project's theme.css starts from the starter's look. dev starts the local editor, which previews every fixture of every template, with no configuration file. Projects that need a different folder layout, a monorepo, or their own Vite plugins add the @samva/vite plugin instead; see Configure the editor plugin.

Define a template

defineTemplate takes an id, an input schema, named fixtures, and one body per channel. Email is the channel available today: email holds a subject, an optional preheader, and a body. Each is a function of the validated input that returns a string or JSX.

templates/order-shipped.tsx
import { defineTemplate } from "@samva/markup";
import { Button, Email, Section } from "@samva/markup/email";
import { fmt } from "@samva/markup/fmt";
import { jsonSchema } from "@samva/markup/input-schema";

export default defineTemplate({
  id: "order-shipped",
  schema: jsonSchema<{
    name: string;
    orderId: string;
    currency: string;
    total: number;
    items: { title: string; qty: number }[];
    trackingUrl?: string;
  }>({
    type: "object",
    properties: {
      name: { type: "string" },
      orderId: { type: "string" },
      currency: { type: "string" },
      total: { type: "number" },
      items: {
        type: "array",
        items: {
          type: "object",
          properties: { title: { type: "string" }, qty: { type: "number" } },
          required: ["title", "qty"],
          additionalProperties: false,
        },
      },
      trackingUrl: { type: "string" },
    },
    required: ["name", "orderId", "currency", "total", "items"],
    additionalProperties: false,
  }),
  fixtures: {
    default: {
      name: "Ada",
      orderId: "A-1042",
      currency: "USD",
      total: 84,
      items: [{ title: "Linen shirt", qty: 2 }],
      trackingUrl: "https://track.example.com/A-1042",
    },
    "no-tracking": {
      name: "Ada",
      orderId: "A-1042",
      currency: "USD",
      total: 84,
      items: [{ title: "Linen shirt", qty: 2 }],
    },
  },
  email: {
    subject: (input) => `Order ${input.orderId} is on its way`,
    preheader: (input) => `${input.items.length} items, ${fmt.money(input.total, input.currency)}`,
    body: (input) => (
      <Email>
        <Section className="px-8 py-6">
          <h1 className="text-2xl font-semibold">Hi {input.name}, it shipped.</h1>
          {input.items.map((item) => (
            <p>
              {item.title} x {item.qty}
            </p>
          ))}
          <p className="font-medium">Total {fmt.money(input.total, input.currency)}</p>
          {input.trackingUrl && (
            <Button href={input.trackingUrl} width={200} height={44}>
              Track package
            </Button>
          )}
        </Section>
      </Email>
    ),
  },
});
  • id is the template's public handle: lowercase kebab-case, the same rule as a template slug. It is unique within the project and independent of the file name.
  • subject and preheader belong to the channel, never to <Email>.
  • schema is a literal jsonSchema<Input>({ ... }). It validates every send and drives the binding checker; see Template inputs.
  • At least one fixture is required. Fixture names start with a letter, digit, or underscore and continue with letters, digits, dots, underscores, or hyphens.
  • The body is one expression built from the forms in the static profile. Anything else is a diagnostic that names the fix.

Preview and check locally

samva templates check
samva templates render order-shipped --fixture no-tracking
samva templates snapshot order-shipped --fixture no-tracking
  • samva templates dev serves the visual editor with every fixture rendered, and writes edits made on the canvas back to the file.
  • samva templates check type-checks and compiles every template, renders every fixture, and reports findings with file:line:column. Add --watch to re-run on every change.
  • samva templates render <template> --fixture <name> prints the subject, preheader, and plain text. --format html prints the HTML, and --locale and --time-zone render for a specific reader.
  • samva templates snapshot <template> --fixture <name> writes desktop and mobile screenshots, in light and dark, to .samva/snapshots.

Add --json to check and render for machine-readable findings. See The agent loop for how agents use these commands.

Publish and send

Commit and push the checked source, then publish an exact commit:

git add templates && git commit -m "Order shipped email" && git push origin main
samva templates publish --commit HEAD

Publishing creates an immutable publication and returns its receipt. Sends render the publication you name, or the template's current one. samva templates types writes TypeScript types for each template's input, so your application code is checked against the same schema:

samva templates types --out src/samva-templates.ts

To choose the locale and time zone a send formats with, see Template inputs.

Send by template slug

The template's id is its slug. After publishing order-shipped, send it from the CLI:

samva email send --to ada@example.com --template-slug order-shipped \
  --template-data '{"name":"Ada","orderId":"A-1042","currency":"USD","total":84,"items":[{"title":"Linen shirt","qty":2}]}'

or from the SDK:

import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });

await samva.email.send({
  to: [{ email: "ada@example.com" }],
  templateSlug: "order-shipped",
  templateData: {
    name: "Ada",
    orderId: "A-1042",
    currency: "USD",
    total: 84,
    items: [{ title: "Linen shirt", qty: 2 }],
  },
});

The send is validated against the published schema, so a missing or mistyped field fails the request. A template send omits subject, html, and text; the template provides them. See Send an email for pinning a publication, locale, and time zone.

To check that call against the template's input, generate the typed helper keyed by slug:

samva templates types --by slug --out src/samva-templates.ts
import { templateContent, type SamvaTemplateInputs, type TemplateSlug } from "./samva-templates";

await samva.email.send({
  to: "ada@example.com",
  ...templateContent("order-shipped", { name: "Ada", orderId: "A-1042" /* ... */ }),
});

// A mailer generic over the template: the data is tied to the slug.
const sendTemplate = <Slug extends TemplateSlug>(slug: Slug, data: SamvaTemplateInputs[Slug]) =>
  samva.email.send({ to: "ada@example.com", ...templateContent(slug, data) });

templateContent returns { templateSlug, templateData }. A send by slug renders the template's current publication, so its types follow the input contract: regenerate after publishing, and samva templates types --by slug --check fails CI when the file no longer matches. The default, --by id, returns { templateId, publicationId } and pins one publication, which a slug cannot carry. In both modes the data you pass is typed as the template's input and rebuilt as JSON values, leaving out a property that is undefined, so an input with optional fields or lists is accepted by email.send as it is. The generated file declares no dependency and passes strict lint configurations without exclusions.

Preview a publication with real input

To see what a publication renders for a given input without sending anything, render it. The input is validated against the publication's schema exactly as a send validates it, and the response is the subject, preheader, HTML, and plain text a send would produce:

const rendered = await samva.templates.renderPublication({
  id: templateId,
  publicationId,
  data: { name: "Ada", orderId: "A-1042", currency: "USD", total: 84, items: [] },
});

console.log(rendered.subject);

Invalid input returns a validation error that names each failing field. Only a publication renders: unpublished edits in the workspace never do. Use samva templates render to render local source before you publish.

Several templates in one project

A project can hold every email your product sends, sharing its partials, theme, and fonts. Register each new file as a template, then publish them together:

samva templates add templates/reset.tsx
git add templates && git commit -m "Add password reset" && git push origin main
samva templates publish --all

samva templates add uses the file's defineTemplate id as the template handle. Pass --slug to choose a different handle. In the dashboard editor, Add email writes a starter and registers it. Coding agents use the templates_add_entry MCP tool.

samva templates publish --all publishes every active template in the project at one commit and reports each result. It exits with an error if any template failed and lists template files you have not registered. Running it again after a failure is safe: templates that already published at that commit return their existing receipt. Publish one template with samva templates publish templates/reset.tsx.

To rename a file, move it with git mv, then run samva templates move templates/reset.tsx templates/account/reset.tsx. The template keeps its id, handle, and publications, so sends keep working. If you delete a template's file, its last publication keeps sending, and publishing that template fails until the file returns.

Images and fonts in a project

A template imports an image from a file in its project, and the import resolves to the hosted URL the publication serves:

templates/launch.tsx
import { defineTemplate } from "@samva/markup";
import { Email, Image, Section } from "@samva/markup/email";
import { jsonSchema } from "@samva/markup/input-schema";

import hero from "../assets/hero.png";

export default defineTemplate({
  id: "launch",
  schema: jsonSchema<{ headline: string }>({
    type: "object",
    properties: { headline: { type: "string" } },
    required: ["headline"],
    additionalProperties: false,
  }),
  fixtures: { default: { headline: "New spring collection" } },
  email: {
    subject: (input) => input.headline,
    body: (input) => (
      <Email>
        <Section>
          <Image src={hero} alt="New spring collection" width={600} height={300} />
          <h1 className="font-brand">{input.headline}</h1>
        </Section>
      </Email>
    ),
  },
});

Set width and height to the size the image displays at, keeping its aspect ratio.

A font is a .woff2 file in the project, declared in theme.css with @font-face and named by a --font-* theme variable. The src path is relative to theme.css:

theme.css
@font-face {
  font-family: "Brand Sans";
  src: url("./fonts/brand-sans-400.woff2") format("woff2");
  font-weight: 400;
}

@theme {
  --font-brand: "Brand Sans", Helvetica, Arial, sans-serif;
}

Gmail and classic Outlook do not load webfonts and show the rest of the stack, so a stack that names a project font must end in sans-serif, serif, or monospace; the check reports email-font-fallback otherwise. Only WOFF2 files are accepted. A font every template shares belongs in your brand instead.

Add a file to a project in any of these ways:

  • Commit it with Git.
  • In the dashboard editor, choose Add file. It writes to the assets folder.
  • Run samva templates workspace add-asset.
  • Call Put a template project asset.
  • Attach the image in the assistant and ask it to add the image, or give the assistant a public .woff2 URL to add a font.

Outside Git, a file is added as an unsaved change. The bytes must be the format the file extension names. The file becomes part of the project when you save a version.

Configure the editor plugin

samva templates dev needs no configuration. For templates in another folder, a monorepo, or a Vite server that also runs your other plugins, install @samva/vite and add the plugin:

vite.config.ts
import { samvaEditor } from "@samva/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [samvaEditor({ templatesDir: "templates" })] });

templatesDir names the one folder to scan. Without it, templates/ and emails/ are both scanned, whichever exist. projectRoot names the folder that holds theme.css and the files templates import. Templates never go through Vite's module graph, so your Vite aliases and plugins do not apply to them: import partials by relative path.

Related documentation

On this page