@maildeno/editor needs a browser — it mounts into the DOM and renders inside a shadow root. In a Next.js App Router project that means keeping it out of server rendering entirely, not just marking the component "use client".

Installation

npm install @maildeno/editor

Why "use client" alone isn’t enough

A "use client" component still gets rendered once on the server for the initial HTML payload. @maildeno/editor has no server-safe render path, so a plain client component will throw during that server pass. The fix is to load the component with ssr: false, which skips server rendering for it entirely.

The editor component

// app/editor/EmailEditor.tsx
"use client";

import { useEffect, useRef } from "react";
import { init, type EditorHandle } from "@maildeno/editor/init";

export default function EmailEditor({ templateId }: { templateId?: string }) {
  const container = useRef<HTMLDivElement>(null);
  const handle = useRef<EditorHandle | null>(null);

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

    init({
      container: container.current!,
      templateId,
      capabilities: { export: ["html", "json"] },
      onSave: (payload: { templateId: string | null }) => {
        console.log("Saved", payload);
        const target = handle?.getHtml(); // handle?.getReactEmail() or handle?.getMjml()
        console.log(target);
      },
      onSendTestEmail: async ({ to, subject, html }) => {
        const res = await fetch("/api/send-test", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ to, subject, html }),
        });
        if (!res.ok) throw new Error("Failed to send test email");
      },
    })

    return () => {
      cancelled = true;
      handle.current?.destroy();
    };
  }, [templateId]);

  return <div ref={container} />;
}

Loading it without SSR

// app/editor/page.tsx
"use client";

import dynamic from "next/dynamic";

const EmailEditor = dynamic(() => import("./EmailEditor"), { ssr: false });

export default function EditorPage() {
  return <EmailEditor />;
}

dynamic(…​, { ssr: false }) is only valid from a Client Component in the App Router — hence "use client" at the top of page.tsx as well, even though its only job here is to render <EmailEditor />.

The send-test API route

// app/api/send-test/route.ts
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const { to, subject, html } = await request.json();

  const res = await fetch("https://api.postmarkapp.com/email", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      "X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN!,
    },
    body: JSON.stringify({
      From: "Your App <preview@yourapp.com>",
      To: to,
      Subject: subject,
      HtmlBody: html,
      MessageStream: "outbound",
    }),
  });

  if (!res.ok) {
    return NextResponse.json({ error: await res.text() }, { status: 502 });
  }

  const data = await res.json();
  return NextResponse.json({ success: true, messageId: data.MessageID });
}

See Test email integration for the full pattern, including swapping in Resend, SendGrid, or SES.

Where to go next