Răsfoiți Sursa

Feat/react email support (#159)

* add react-email workspace dependencies

* add react-email package

* add react-email example with Vercel Invite, Stripe Welcome and Nike Receipt

* add render tests for react-email package

* fix missing style prop on stripe-welcome logo Image

* add react email documentation

* add json render react email skill

* fix @internal/react-state alias in vitest config

Pre-existing issue: vitest could not resolve @internal/react-state,
causing 6 test suites in packages/react and packages/react-pdf to fail.

* fix PR review feedback

- fix tsconfig: remove rootDir, expand include for test imports
- fix render tests: add non-null assertions for noUncheckedIndexedAccess
- fix dev script: add portless to match other examples
- fix .env.example: correct comment from video to email generation
- fix docs: add blank line before heading in installation page

* merge latest

* fix: update tsconfig extends from @repo to @internal/typescript-config

* fixes

* fixes

---------

Co-authored-by: WManzoli <willmanzoli@gmail.com>
Co-authored-by: Chris Tate <chris@ctate.dev>
Lucian Fialho 4 luni în urmă
părinte
comite
b6f12d4d53
59 a modificat fișierele cu 7333 adăugiri și 3 ștergeri
  1. 1 0
      .changeset/config.json
  2. 38 0
      README.md
  3. 310 0
      apps/web/app/(main)/docs/api/react-email/page.mdx
  4. 4 0
      apps/web/app/(main)/docs/installation/page.mdx
  5. 26 0
      apps/web/app/(main)/docs/registry/page.mdx
  6. 1 1
      apps/web/app/api/docs-chat/route.ts
  7. 6 0
      apps/web/lib/docs-navigation.ts
  8. 1 0
      apps/web/lib/page-titles.ts
  9. 9 0
      examples/react-email/.env.example
  10. 47 0
      examples/react-email/app/api/email/route.ts
  11. 34 0
      examples/react-email/app/api/generate/route.ts
  12. 130 0
      examples/react-email/app/globals.css
  13. 23 0
      examples/react-email/app/layout.tsx
  14. 608 0
      examples/react-email/app/page.tsx
  15. 23 0
      examples/react-email/components.json
  16. 64 0
      examples/react-email/components/ui/button.tsx
  17. 53 0
      examples/react-email/components/ui/resizable.tsx
  18. 58 0
      examples/react-email/components/ui/scroll-area.tsx
  19. 28 0
      examples/react-email/components/ui/separator.tsx
  20. 143 0
      examples/react-email/components/ui/sheet.tsx
  21. 91 0
      examples/react-email/components/ui/tabs.tsx
  22. 18 0
      examples/react-email/components/ui/textarea.tsx
  23. 8 0
      examples/react-email/lib/catalog.ts
  24. 1055 0
      examples/react-email/lib/examples.ts
  25. 6 0
      examples/react-email/lib/utils.ts
  26. 6 0
      examples/react-email/next-env.d.ts
  27. 11 0
      examples/react-email/next.config.ts
  28. 39 0
      examples/react-email/package.json
  29. 8 0
      examples/react-email/postcss.config.mjs
  30. BIN
      examples/react-email/public/static/nike-logo.png
  31. BIN
      examples/react-email/public/static/nike-product.png
  32. BIN
      examples/react-email/public/static/stripe-logo.png
  33. BIN
      examples/react-email/public/static/vercel-arrow.png
  34. BIN
      examples/react-email/public/static/vercel-logo.png
  35. BIN
      examples/react-email/public/static/vercel-team.png
  36. BIN
      examples/react-email/public/static/vercel-user.png
  37. 11 0
      examples/react-email/tsconfig.json
  38. 147 0
      packages/react-email/README.md
  39. 78 0
      packages/react-email/package.json
  40. 1057 0
      packages/react-email/src/__fixtures__/examples.ts
  41. 41 0
      packages/react-email/src/catalog-types.ts
  42. 219 0
      packages/react-email/src/catalog.ts
  43. 1 0
      packages/react-email/src/components/index.ts
  44. 212 0
      packages/react-email/src/components/standard.tsx
  45. 281 0
      packages/react-email/src/contexts/actions.tsx
  46. 26 0
      packages/react-email/src/contexts/repeat-scope.tsx
  47. 117 0
      packages/react-email/src/contexts/state.tsx
  48. 262 0
      packages/react-email/src/contexts/validation.tsx
  49. 64 0
      packages/react-email/src/contexts/visibility.tsx
  50. 87 0
      packages/react-email/src/index.ts
  51. 407 0
      packages/react-email/src/render.test.tsx
  52. 168 0
      packages/react-email/src/render.tsx
  53. 502 0
      packages/react-email/src/renderer.tsx
  54. 57 0
      packages/react-email/src/schema.ts
  55. 20 0
      packages/react-email/src/server.ts
  56. 8 0
      packages/react-email/tsconfig.json
  57. 16 0
      packages/react-email/tsup.config.ts
  58. 528 2
      pnpm-lock.yaml
  59. 175 0
      skills/json-render-react-email/SKILL.md

+ 1 - 0
.changeset/config.json

@@ -6,6 +6,7 @@
     [
       "@json-render/core",
       "@json-render/react",
+      "@json-render/react-email",
       "@json-render/react-pdf",
       "@json-render/shadcn",
       "@json-render/react-native",

+ 38 - 0
README.md

@@ -15,6 +15,8 @@ npm install @json-render/core @json-render/react-native
 npm install @json-render/core @json-render/remotion
 # or for PDF documents
 npm install @json-render/core @json-render/react-pdf
+# or for HTML email
+npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
 # or for Vue
 npm install @json-render/core @json-render/vue
 ```
@@ -118,6 +120,7 @@ function Dashboard({ spec }) {
 | `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
 | `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
+| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
 | `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
 | `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
 | `@json-render/zustand` | Zustand adapter for `StateStore` |
@@ -288,6 +291,40 @@ const spec = {
 const buffer = await renderToBuffer(spec);
 ```
 
+### React Email (Email)
+
+```typescript
+import { renderToHtml } from "@json-render/react-email";
+import { schema, standardComponentDefinitions } from "@json-render/react-email";
+import { defineCatalog } from "@json-render/core";
+
+const catalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+});
+
+const spec = {
+  root: "html-1",
+  elements: {
+    "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
+    "head-1": { type: "Head", props: {}, children: [] },
+    "body-1": {
+      type: "Body",
+      props: { style: { backgroundColor: "#f6f9fc" } },
+      children: ["container-1"],
+    },
+    "container-1": {
+      type: "Container",
+      props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
+      children: ["heading-1", "text-1"],
+    },
+    "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
+    "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
+  },
+};
+
+const html = await renderToHtml(spec);
+```
+
 ### Image (SVG/PNG)
 
 ```typescript
@@ -422,6 +459,7 @@ pnpm dev
 
 - http://json-render.localhost:1355 - Docs & Playground
 - http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
+- http://react-email-demo.json-render.localhost:1355 - React Email Example
 - http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
 - Chat Example: run `pnpm dev` in `examples/chat`
 - Vue Example: run `pnpm dev` in `examples/vue`

+ 310 - 0
apps/web/app/(main)/docs/api/react-email/page.mdx

@@ -0,0 +1,310 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/react-email")
+
+# @json-render/react-email
+
+React Email renderer. Turn JSON specs into HTML or plain-text emails using `@react-email/components` and `@react-email/render`.
+
+## Install
+
+```bash
+npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
+```
+
+See the [React Email example](https://github.com/vercel-labs/json-render/tree/main/examples/react-email) for a full working example.
+
+## schema
+
+The email element schema for specs. Use with `defineCatalog` from core.
+
+```typescript
+import { defineCatalog } from '@json-render/core';
+import { schema, standardComponentDefinitions } from '@json-render/react-email';
+
+const catalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+});
+```
+
+## Render Functions
+
+Server-side functions for producing email output. All accept a spec and optional `RenderOptions`.
+
+```typescript
+import { renderToHtml, renderToPlainText } from '@json-render/react-email';
+
+const html = await renderToHtml(spec);
+
+const plainText = await renderToPlainText(spec);
+```
+
+### RenderOptions
+
+```typescript
+interface RenderOptions {
+  registry?: ComponentRegistry;
+  includeStandard?: boolean;  // default: true
+  state?: Record<string, unknown>;
+}
+```
+
+<table>
+  <thead>
+    <tr>
+      <th>Option</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>registry</code></td>
+      <td>Custom component map (merged with standard components)</td>
+    </tr>
+    <tr>
+      <td><code>includeStandard</code></td>
+      <td>Include built-in standard components (default: <code>true</code>)</td>
+    </tr>
+    <tr>
+      <td><code>state</code></td>
+      <td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
+    </tr>
+  </tbody>
+</table>
+
+## defineRegistry
+
+Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
+
+```tsx
+import { defineRegistry } from '@json-render/react-email';
+import { Container, Heading, Text } from '@react-email/components';
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <Container style={{ padding: 16, backgroundColor: '#fff' }}>
+        <Heading>{props.title}</Heading>
+        {children}
+      </Container>
+    ),
+  },
+});
+
+const html = await renderToHtml(spec, { registry });
+```
+
+## createRenderer
+
+Create a standalone renderer component wired to state, actions, and validation (for interactive previews in the browser).
+
+```typescript
+import { createRenderer } from '@json-render/react-email';
+
+const EmailRenderer = createRenderer(catalog, components);
+```
+
+## Renderer
+
+The main component that renders a spec to React Email elements. Use inside `JSONUIProvider` when you need state, actions, or visibility.
+
+```typescript
+interface RendererProps {
+  spec: Spec | null;
+  registry?: ComponentRegistry;
+  includeStandard?: boolean;  // default: true
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+```
+
+## Standard Components
+
+### Document structure
+
+<table>
+  <thead>
+    <tr>
+      <th>Component</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>Html</code></td>
+      <td>Top-level email wrapper. Must be the root element.</td>
+    </tr>
+    <tr>
+      <td><code>Head</code></td>
+      <td>Email head section. Place inside Html.</td>
+    </tr>
+    <tr>
+      <td><code>Body</code></td>
+      <td>Email body wrapper. Place inside Html.</td>
+    </tr>
+  </tbody>
+</table>
+
+### Layout
+
+<table>
+  <thead>
+    <tr>
+      <th>Component</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>Container</code></td>
+      <td>Constrains content width (e.g. max-width 600px).</td>
+    </tr>
+    <tr>
+      <td><code>Section</code></td>
+      <td>Groups related content.</td>
+    </tr>
+    <tr>
+      <td><code>Row</code></td>
+      <td>Horizontal layout row.</td>
+    </tr>
+    <tr>
+      <td><code>Column</code></td>
+      <td>Column within a Row.</td>
+    </tr>
+  </tbody>
+</table>
+
+### Content
+
+<table>
+  <thead>
+    <tr>
+      <th>Component</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>Heading</code></td>
+      <td>Heading text (h1-h6).</td>
+    </tr>
+    <tr>
+      <td><code>Text</code></td>
+      <td>Body text paragraph.</td>
+    </tr>
+    <tr>
+      <td><code>Link</code></td>
+      <td>Hyperlink with text and href.</td>
+    </tr>
+    <tr>
+      <td><code>Button</code></td>
+      <td>Call-to-action button (link styled as button).</td>
+    </tr>
+    <tr>
+      <td><code>Image</code></td>
+      <td>Image from URL.</td>
+    </tr>
+    <tr>
+      <td><code>Hr</code></td>
+      <td>Horizontal rule separator.</td>
+    </tr>
+  </tbody>
+</table>
+
+### Utility
+
+<table>
+  <thead>
+    <tr>
+      <th>Component</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>Preview</code></td>
+      <td>Preview text for inbox (inside Html).</td>
+    </tr>
+    <tr>
+      <td><code>Markdown</code></td>
+      <td>Renders markdown content as email-safe HTML.</td>
+    </tr>
+  </tbody>
+</table>
+
+## Server-Safe Import
+
+Import schema and catalog definitions without pulling in React or `@react-email/components`:
+
+```typescript
+import { schema, standardComponentDefinitions } from '@json-render/react-email/server';
+```
+
+## Sub-path Exports
+
+<table>
+  <thead>
+    <tr>
+      <th>Export</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>@json-render/react-email</code></td>
+      <td>Full package: schema, renderer, components, render functions</td>
+    </tr>
+    <tr>
+      <td><code>@json-render/react-email/server</code></td>
+      <td>Schema and catalog definitions only (no React)</td>
+    </tr>
+    <tr>
+      <td><code>@json-render/react-email/catalog</code></td>
+      <td>Standard component definitions and types</td>
+    </tr>
+    <tr>
+      <td><code>@json-render/react-email/render</code></td>
+      <td>Server-side render functions only</td>
+    </tr>
+  </tbody>
+</table>
+
+## Types
+
+<table>
+  <thead>
+    <tr>
+      <th>Export</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>ReactEmailSchema</code></td>
+      <td>Schema type for email specs</td>
+    </tr>
+    <tr>
+      <td><code>ReactEmailSpec</code></td>
+      <td>Spec type for email documents</td>
+    </tr>
+    <tr>
+      <td><code>RenderOptions</code></td>
+      <td>Options for render functions</td>
+    </tr>
+    <tr>
+      <td><code>ComponentContext</code></td>
+      <td>Typed component render function context</td>
+    </tr>
+    <tr>
+      <td><code>ComponentFn</code></td>
+      <td>Component render function type</td>
+    </tr>
+    <tr>
+      <td><code>StandardComponentDefinitions</code></td>
+      <td>Type of the standard component definitions object</td>
+    </tr>
+    <tr>
+      <td><code>StandardComponentProps&lt;K&gt;</code></td>
+      <td>Inferred props type for a standard component by name</td>
+    </tr>
+  </tbody>
+</table>

+ 4 - 0
apps/web/app/(main)/docs/installation/page.mdx

@@ -37,6 +37,10 @@ Requires Tailwind CSS in your project. See the [@json-render/shadcn API referenc
 
 <PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
 
+## For React Email
+
+<PackageInstall packages="@json-render/core @json-render/react-email @react-email/components @react-email/render" />
+
 ## For External State Management (Optional)
 
 If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library:

+ 26 - 0
apps/web/app/(main)/docs/registry/page.mdx

@@ -9,6 +9,7 @@ What a registry contains depends on the schema you use. Each package defines its
 
 - **`@json-render/react`** — Components (React elements) and action handlers
 - **`@json-render/react-native`** — Components (React Native elements) and action handlers
+- **`@json-render/react-email`** — Email components (React Email / HTML)
 - **`@json-render/remotion`** — Clip components, transitions, and effects
 
 ## @json-render/react
@@ -278,6 +279,31 @@ export const { registry } = defineRegistry(catalog, {
 
 See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
 
+## @json-render/react-email
+
+`@json-render/react-email` uses `defineRegistry` like React and React Native. Components render to React Email primitives (`@react-email/components`). Use `renderToHtml` or `renderToPlainText` for server-side email output:
+
+```tsx
+import { defineRegistry } from '@json-render/react-email';
+import { renderToHtml } from '@json-render/react-email';
+import { Body, Container, Heading, Text } from '@react-email/components';
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <Container style={{ padding: 16, backgroundColor: '#fff' }}>
+        <Heading>{props.title}</Heading>
+        {children}
+      </Container>
+    ),
+  },
+});
+
+const html = await renderToHtml(spec, { registry });
+```
+
+See the [@json-render/react-email API reference](/docs/api/react-email) for the full API.
+
 ## @json-render/remotion
 
 `@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:

+ 1 - 1
apps/web/app/api/docs-chat/route.ts

@@ -16,7 +16,7 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
 
 GitHub repository: https://github.com/vercel-labs/json-render
 Documentation: https://json-render.dev/docs
-npm packages: @json-render/core, @json-render/react, @json-render/image, @json-render/remotion, @json-render/codegen
+npm packages: @json-render/core, @json-render/react, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen
 
 You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
 

+ 6 - 0
apps/web/lib/docs-navigation.ts

@@ -65,6 +65,11 @@ export const docsNavigation: NavSection[] = [
         href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
         external: true,
       },
+      {
+        title: "React Email",
+        href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email",
+        external: true,
+      },
       {
         title: "Remotion",
         href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
@@ -110,6 +115,7 @@ export const docsNavigation: NavSection[] = [
       { title: "@json-render/core", href: "/docs/api/core" },
       { title: "@json-render/react", href: "/docs/api/react" },
       { title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
+      { title: "@json-render/react-email", href: "/docs/api/react-email" },
       { title: "@json-render/shadcn", href: "/docs/api/shadcn" },
       { title: "@json-render/react-native", href: "/docs/api/react-native" },
       { title: "@json-render/image", href: "/docs/api/image" },

+ 1 - 0
apps/web/lib/page-titles.ts

@@ -43,6 +43,7 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/api/react": "@json-render/react API",
   "docs/api/vue": "@json-render/vue API",
   "docs/api/react-pdf": "@json-render/react-pdf API",
+  "docs/api/react-email": "@json-render/react-email API",
   "docs/api/react-native": "@json-render/react-native API",
   "docs/api/codegen": "@json-render/codegen API",
   "docs/api/image": "@json-render/image API",

+ 9 - 0
examples/react-email/.env.example

@@ -0,0 +1,9 @@
+# Vercel AI Gateway
+# Automatically authenticated when deployed on Vercel
+# For local development, get your key from https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=
+
+# AI Model Configuration
+# Override the default model used for email generation
+# Default: anthropic/claude-haiku-4.5
+AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5

+ 47 - 0
examples/react-email/app/api/email/route.ts

@@ -0,0 +1,47 @@
+import {
+  renderToHtml,
+  renderToPlainText,
+} from "@json-render/react-email/render";
+import { examples } from "@/lib/examples";
+import type { Spec } from "@json-render/core";
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const name = searchParams.get("name") ?? "vercel-invite";
+  const plain = searchParams.get("plain") === "1";
+
+  const example = examples.find((e) => e.name === name);
+  if (!example) {
+    return new Response("Example not found", { status: 404 });
+  }
+
+  return emailResponse(example.spec, plain);
+}
+
+export async function POST(req: Request) {
+  const { spec, plain } = (await req.json()) as {
+    spec: Spec;
+    plain?: boolean;
+  };
+
+  if (!spec || !spec.root || !spec.elements) {
+    return new Response("Invalid spec", { status: 400 });
+  }
+
+  return emailResponse(spec, plain ?? false);
+}
+
+async function emailResponse(spec: Spec, plain: boolean) {
+  const content = plain
+    ? await renderToPlainText(spec)
+    : await renderToHtml(spec);
+
+  return new Response(content, {
+    headers: {
+      "Content-Type": plain
+        ? "text/plain; charset=utf-8"
+        : "text/html; charset=utf-8",
+      "Cache-Control": "no-store",
+    },
+  });
+}

+ 34 - 0
examples/react-email/app/api/generate/route.ts

@@ -0,0 +1,34 @@
+import { streamText } from "ai";
+import { buildUserPrompt, type Spec } from "@json-render/core";
+import { emailCatalog } from "@/lib/catalog";
+
+export const maxDuration = 60;
+
+const SYSTEM_PROMPT = emailCatalog.prompt();
+
+const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+
+export async function POST(req: Request) {
+  const { prompt, startingSpec } = (await req.json()) as {
+    prompt: string;
+    startingSpec?: Spec | null;
+  };
+
+  if (!prompt || typeof prompt !== "string") {
+    return Response.json({ error: "prompt is required" }, { status: 400 });
+  }
+
+  const userPrompt = buildUserPrompt({
+    prompt,
+    currentSpec: startingSpec,
+  });
+
+  const result = streamText({
+    model: process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL,
+    system: SYSTEM_PROMPT,
+    prompt: userPrompt,
+    temperature: 0.7,
+  });
+
+  return result.toTextStreamResponse();
+}

+ 130 - 0
examples/react-email/app/globals.css

@@ -0,0 +1,130 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+    --radius-sm: calc(var(--radius) - 4px);
+    --radius-md: calc(var(--radius) - 2px);
+    --radius-lg: var(--radius);
+    --radius-xl: calc(var(--radius) + 4px);
+    --radius-2xl: calc(var(--radius) + 8px);
+    --radius-3xl: calc(var(--radius) + 12px);
+    --radius-4xl: calc(var(--radius) + 16px);
+    --color-background: var(--background);
+    --color-foreground: var(--foreground);
+    --color-card: var(--card);
+    --color-card-foreground: var(--card-foreground);
+    --color-popover: var(--popover);
+    --color-popover-foreground: var(--popover-foreground);
+    --color-primary: var(--primary);
+    --color-primary-foreground: var(--primary-foreground);
+    --color-secondary: var(--secondary);
+    --color-secondary-foreground: var(--secondary-foreground);
+    --color-muted: var(--muted);
+    --color-muted-foreground: var(--muted-foreground);
+    --color-accent: var(--accent);
+    --color-accent-foreground: var(--accent-foreground);
+    --color-destructive: var(--destructive);
+    --color-border: var(--border);
+    --color-input: var(--input);
+    --color-ring: var(--ring);
+    --color-chart-1: var(--chart-1);
+    --color-chart-2: var(--chart-2);
+    --color-chart-3: var(--chart-3);
+    --color-chart-4: var(--chart-4);
+    --color-chart-5: var(--chart-5);
+    --color-sidebar: var(--sidebar);
+    --color-sidebar-foreground: var(--sidebar-foreground);
+    --color-sidebar-primary: var(--sidebar-primary);
+    --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+    --color-sidebar-accent: var(--sidebar-accent);
+    --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+    --color-sidebar-border: var(--sidebar-border);
+    --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+    --radius: 0.625rem;
+    --background: oklch(1 0 0);
+    --foreground: oklch(0.145 0 0);
+    --card: oklch(1 0 0);
+    --card-foreground: oklch(0.145 0 0);
+    --popover: oklch(1 0 0);
+    --popover-foreground: oklch(0.145 0 0);
+    --primary: oklch(0.205 0 0);
+    --primary-foreground: oklch(0.985 0 0);
+    --secondary: oklch(0.97 0 0);
+    --secondary-foreground: oklch(0.205 0 0);
+    --muted: oklch(0.97 0 0);
+    --muted-foreground: oklch(0.556 0 0);
+    --accent: oklch(0.97 0 0);
+    --accent-foreground: oklch(0.205 0 0);
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: oklch(0.922 0 0);
+    --input: oklch(0.922 0 0);
+    --ring: oklch(0.708 0 0);
+    --chart-1: oklch(0.646 0.222 41.116);
+    --chart-2: oklch(0.6 0.118 184.704);
+    --chart-3: oklch(0.398 0.07 227.392);
+    --chart-4: oklch(0.828 0.189 84.429);
+    --chart-5: oklch(0.769 0.188 70.08);
+    --sidebar: oklch(0.985 0 0);
+    --sidebar-foreground: oklch(0.145 0 0);
+    --sidebar-primary: oklch(0.205 0 0);
+    --sidebar-primary-foreground: oklch(0.985 0 0);
+    --sidebar-accent: oklch(0.97 0 0);
+    --sidebar-accent-foreground: oklch(0.205 0 0);
+    --sidebar-border: oklch(0.922 0 0);
+    --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+    --background: oklch(0.145 0 0);
+    --foreground: oklch(0.985 0 0);
+    --card: oklch(0.205 0 0);
+    --card-foreground: oklch(0.985 0 0);
+    --popover: oklch(0.205 0 0);
+    --popover-foreground: oklch(0.985 0 0);
+    --primary: oklch(0.922 0 0);
+    --primary-foreground: oklch(0.205 0 0);
+    --secondary: oklch(0.269 0 0);
+    --secondary-foreground: oklch(0.985 0 0);
+    --muted: oklch(0.269 0 0);
+    --muted-foreground: oklch(0.708 0 0);
+    --accent: oklch(0.269 0 0);
+    --accent-foreground: oklch(0.985 0 0);
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: oklch(1 0 0 / 10%);
+    --input: oklch(1 0 0 / 15%);
+    --ring: oklch(0.556 0 0);
+    --chart-1: oklch(0.488 0.243 264.376);
+    --chart-2: oklch(0.696 0.17 162.48);
+    --chart-3: oklch(0.769 0.188 70.08);
+    --chart-4: oklch(0.627 0.265 303.9);
+    --chart-5: oklch(0.645 0.246 16.439);
+    --sidebar: oklch(0.205 0 0);
+    --sidebar-foreground: oklch(0.985 0 0);
+    --sidebar-primary: oklch(0.488 0.243 264.376);
+    --sidebar-primary-foreground: oklch(0.985 0 0);
+    --sidebar-accent: oklch(0.269 0 0);
+    --sidebar-accent-foreground: oklch(0.985 0 0);
+    --sidebar-border: oklch(1 0 0 / 10%);
+    --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}

+ 23 - 0
examples/react-email/app/layout.tsx

@@ -0,0 +1,23 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+  title: "json-render React Email Example",
+  description:
+    "Generate HTML emails from JSON specs with @json-render/react-email",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body className={inter.className}>{children}</body>
+    </html>
+  );
+}

+ 608 - 0
examples/react-email/app/page.tsx

@@ -0,0 +1,608 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import { examples } from "@/lib/examples";
+import { createSpecStreamCompiler } from "@json-render/core";
+import type { Spec } from "@json-render/core";
+import { cn } from "@/lib/utils";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
+import {
+  ResizablePanelGroup,
+  ResizablePanel,
+  ResizableHandle,
+} from "@/components/ui/resizable";
+import { FileText, Download, Loader2, ArrowRight, Square } from "lucide-react";
+
+type Mode = "scratch" | "example";
+type MobileView = "json" | "preview";
+
+interface Selection {
+  mode: Mode;
+  exampleName?: string;
+}
+
+const HTML_REFRESH_INTERVAL_MS = 2000;
+
+function CopyButton({ text }: { text: string }) {
+  const [copied, setCopied] = useState(false);
+
+  return (
+    <button
+      onClick={() => {
+        navigator.clipboard.writeText(text);
+        setCopied(true);
+        setTimeout(() => setCopied(false), 1500);
+      }}
+      className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
+    >
+      {copied ? "copied" : "copy"}
+    </button>
+  );
+}
+
+function isRenderableSpec(spec: Spec | null): spec is Spec {
+  if (!spec?.root || !spec.elements) return false;
+  const root = spec.elements[spec.root];
+  if (!root) return false;
+  if (root.type !== "Html" || !root.children?.length) return false;
+  const childTypes = root.children.map((id) => spec.elements[id]?.type);
+  return childTypes.includes("Head") || childTypes.includes("Body");
+}
+
+export default function Page() {
+  const [selection, setSelection] = useState<Selection>({
+    mode: "example",
+    exampleName: examples[0]!.name,
+  });
+  const [prompt, setPrompt] = useState("");
+  const [generating, setGenerating] = useState(false);
+  const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
+  const [htmlContent, setHtmlContent] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [mobileView, setMobileView] = useState<MobileView>("preview");
+  const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
+  const [refreshing, setRefreshing] = useState(false);
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const mobileInputRef = useRef<HTMLTextAreaElement>(null);
+  const abortRef = useRef<AbortController | null>(null);
+  const codeScrollRef = useRef<HTMLDivElement>(null);
+  const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!generating) return;
+    codeScrollRef.current?.scrollTo({
+      top: codeScrollRef.current.scrollHeight,
+    });
+    mobileCodeScrollRef.current?.scrollTo({
+      top: mobileCodeScrollRef.current.scrollHeight,
+    });
+  }, [generating, generatedSpec]);
+
+  const currentExample =
+    selection.mode === "example"
+      ? examples.find((e) => e.name === selection.exampleName)
+      : null;
+
+  const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
+
+  useEffect(() => {
+    inputRef.current?.focus();
+  }, [selection.mode, selection.exampleName]);
+
+  const fetchHtml = useCallback(async (spec: Spec, signal?: AbortSignal) => {
+    const res = await fetch("/api/email", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ spec }),
+      signal,
+    });
+    if (!res.ok) throw new Error("Failed to render email");
+    const html = await res.text();
+    setHtmlContent(html);
+  }, []);
+
+  // Fetch HTML for example specs on selection change
+  useEffect(() => {
+    if (selection.mode !== "example" || !currentExample || generatedSpec)
+      return;
+    fetchHtml(currentExample.spec).catch(() => {});
+  }, [selection.mode, currentExample, generatedSpec, fetchHtml]);
+
+  // Progressive HTML refresh during generation
+  const lastRefreshSpec = useRef<string>("");
+  const generatedSpecRef = useRef<Spec | null>(null);
+  generatedSpecRef.current = generatedSpec;
+
+  useEffect(() => {
+    if (!generating) return;
+
+    const interval = setInterval(() => {
+      const spec = generatedSpecRef.current;
+      if (!spec) return;
+
+      const specKey = JSON.stringify(spec);
+      if (specKey === lastRefreshSpec.current) return;
+      if (!isRenderableSpec(spec)) return;
+
+      lastRefreshSpec.current = specKey;
+      setRefreshing(true);
+      fetchHtml(spec)
+        .catch(() => {})
+        .finally(() => setRefreshing(false));
+    }, HTML_REFRESH_INTERVAL_MS);
+
+    return () => clearInterval(interval);
+  }, [generating, fetchHtml]);
+
+  const handleGenerate = useCallback(async () => {
+    if (!prompt.trim()) return;
+
+    abortRef.current?.abort();
+    const controller = new AbortController();
+    abortRef.current = controller;
+
+    setGenerating(true);
+    setError(null);
+    lastRefreshSpec.current = "";
+
+    try {
+      const startingSpec =
+        selection.mode === "example" && currentExample
+          ? currentExample.spec
+          : null;
+
+      const res = await fetch("/api/generate", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
+        signal: controller.signal,
+      });
+      if (!res.ok) throw new Error("Generation failed");
+
+      const reader = res.body?.getReader();
+      if (!reader) throw new Error("No response body");
+
+      const decoder = new TextDecoder();
+      const compiler = createSpecStreamCompiler<Spec>(
+        startingSpec ? { ...startingSpec } : {},
+      );
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+        const chunk = decoder.decode(value, { stream: true });
+        const { result, newPatches } = compiler.push(chunk);
+        if (newPatches.length > 0) setGeneratedSpec(result);
+      }
+
+      const finalSpec = compiler.getResult();
+      setGeneratedSpec(finalSpec);
+      setGenerating(false);
+
+      await fetchHtml(finalSpec);
+    } catch (e) {
+      if (controller.signal.aborted) return;
+      setError(e instanceof Error ? e.message : "Something went wrong");
+      setGenerating(false);
+    }
+  }, [prompt, selection, currentExample, fetchHtml]);
+
+  const handleStop = useCallback(() => {
+    abortRef.current?.abort();
+    setGenerating(false);
+
+    if (isRenderableSpec(generatedSpec)) {
+      fetchHtml(generatedSpec).catch(() => {});
+    }
+  }, [generatedSpec, fetchHtml]);
+
+  const select = (next: Selection) => {
+    abortRef.current?.abort();
+    setSelection(next);
+    setGeneratedSpec(null);
+    setHtmlContent(null);
+    setError(null);
+    setPrompt("");
+    setGenerating(false);
+    setExamplesSheetOpen(false);
+  };
+
+  const handleDownload = async () => {
+    if (!activeSpec) return;
+    const spec = generatedSpec ?? currentExample?.spec;
+    if (!spec) return;
+
+    const res = await fetch("/api/email", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ spec }),
+    });
+    if (!res.ok) return;
+    const html = await res.text();
+    const blob = new Blob([html], { type: "text/html" });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = "email.html";
+    a.click();
+    URL.revokeObjectURL(url);
+  };
+
+  const handleKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      if (e.key === "Enter" && !e.shiftKey) {
+        e.preventDefault();
+        handleGenerate();
+      }
+    },
+    [handleGenerate],
+  );
+
+  const jsonCode = activeSpec
+    ? JSON.stringify(activeSpec, null, 2)
+    : "// select an example or generate an email";
+
+  // ---------------------------------------------------------------------------
+  // Pane: Chat / Examples
+  // ---------------------------------------------------------------------------
+  const chatPane = (
+    <div className="h-full flex flex-col">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-2">
+        <FileText className="h-3.5 w-3.5 text-muted-foreground" />
+        <span className="text-xs font-mono text-muted-foreground">
+          json-render / react-email
+        </span>
+      </div>
+
+      <ScrollArea className="flex-1">
+        <div className="p-2 space-y-1">
+          <p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
+            start
+          </p>
+          <button
+            onClick={() => select({ mode: "scratch" })}
+            className={cn(
+              "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+              selection.mode === "scratch"
+                ? "bg-muted text-foreground"
+                : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+            )}
+          >
+            <span className="font-medium">From scratch</span>
+          </button>
+
+          <p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
+            examples
+          </p>
+          {examples.map((ex) => (
+            <button
+              key={ex.name}
+              onClick={() => select({ mode: "example", exampleName: ex.name })}
+              className={cn(
+                "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+                selection.mode === "example" &&
+                  selection.exampleName === ex.name
+                  ? "bg-muted text-foreground"
+                  : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+              )}
+            >
+              <span className="font-medium">{ex.label}</span>
+              <p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
+                {ex.description}
+              </p>
+            </button>
+          ))}
+        </div>
+      </ScrollArea>
+
+      <div
+        className="border-t border-border p-3 cursor-text"
+        onMouseDown={(e) => {
+          const target = e.target as HTMLElement;
+          if (!target.closest("button") && target.tagName !== "TEXTAREA") {
+            e.preventDefault();
+            inputRef.current?.focus();
+          }
+        }}
+      >
+        {error && (
+          <div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
+            {error}
+          </div>
+        )}
+        <textarea
+          ref={inputRef}
+          value={prompt}
+          onChange={(e) => setPrompt(e.target.value)}
+          onKeyDown={handleKeyDown}
+          placeholder={
+            selection.mode === "scratch"
+              ? "Describe the email you want..."
+              : `Modify the ${currentExample?.label ?? "example"}...`
+          }
+          className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
+          rows={2}
+          autoFocus
+        />
+        <div className="flex justify-between items-center mt-2">
+          <span className="text-[11px] text-muted-foreground">
+            {selection.mode === "example" && currentExample
+              ? currentExample.label
+              : "scratch"}
+          </span>
+          {generating ? (
+            <button
+              onClick={handleStop}
+              className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
+              aria-label="Stop"
+            >
+              <Square className="h-3 w-3" fill="currentColor" />
+            </button>
+          ) : (
+            <button
+              onClick={handleGenerate}
+              disabled={!prompt.trim()}
+              className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
+              aria-label="Generate"
+            >
+              <ArrowRight className="h-3.5 w-3.5" />
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+
+  // ---------------------------------------------------------------------------
+  // Pane: JSON Spec
+  // ---------------------------------------------------------------------------
+  const codePane = (
+    <div className="h-full flex flex-col">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-3">
+        <span className="text-xs font-mono text-foreground">json</span>
+        {generating && (
+          <Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
+        )}
+        <div className="flex-1" />
+        {activeSpec && <CopyButton text={jsonCode} />}
+      </div>
+      <div ref={codeScrollRef} className="flex-1 overflow-auto">
+        <pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
+          {jsonCode}
+        </pre>
+      </div>
+    </div>
+  );
+
+  // ---------------------------------------------------------------------------
+  // Pane: Email Preview
+  // ---------------------------------------------------------------------------
+  const previewPane = (
+    <div className="h-full flex flex-col">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-3">
+        <span className="text-xs font-mono text-foreground">preview</span>
+        {(generating || refreshing) && (
+          <Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
+        )}
+        <div className="flex-1" />
+        {activeSpec && (
+          <button
+            onClick={handleDownload}
+            className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
+          >
+            <Download className="h-3 w-3" />
+            download
+          </button>
+        )}
+      </div>
+      <div className="flex-1 relative bg-neutral-600">
+        {htmlContent ? (
+          <div className="h-full flex justify-center p-5 overflow-auto">
+            <iframe
+              srcDoc={htmlContent}
+              className="w-[620px] h-full border-none bg-white shadow-sm"
+              title="Email preview"
+            />
+          </div>
+        ) : (
+          <div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
+            <FileText className="h-10 w-10" />
+            <p className="text-sm">
+              {selection.mode === "scratch"
+                ? "Enter a prompt to generate an email"
+                : "Select an example to preview"}
+            </p>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+
+  // ---------------------------------------------------------------------------
+  // Render
+  // ---------------------------------------------------------------------------
+  return (
+    <div className="h-dvh flex flex-col">
+      {/* Desktop: 3-pane resizable layout */}
+      <div className="hidden lg:flex flex-1 min-h-0">
+        <ResizablePanelGroup className="flex-1">
+          <ResizablePanel defaultSize={25} minSize={15}>
+            {chatPane}
+          </ResizablePanel>
+          <ResizableHandle />
+          <ResizablePanel defaultSize={35} minSize={20}>
+            {codePane}
+          </ResizablePanel>
+          <ResizableHandle />
+          <ResizablePanel defaultSize={40} minSize={20}>
+            {previewPane}
+          </ResizablePanel>
+        </ResizablePanelGroup>
+      </div>
+
+      {/* Mobile: toolbar + content + prompt */}
+      <div className="flex lg:hidden flex-col flex-1 min-h-0">
+        <div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0">
+          <button
+            onClick={() => setExamplesSheetOpen(true)}
+            className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
+          >
+            {selection.mode === "example" && currentExample
+              ? currentExample.label
+              : "scratch"}
+          </button>
+          {(["json", "preview"] as const).map((tab) => (
+            <button
+              key={tab}
+              onClick={() => setMobileView(tab)}
+              className={cn(
+                "text-xs font-mono transition-colors shrink-0",
+                mobileView === tab
+                  ? "text-foreground"
+                  : "text-muted-foreground hover:text-foreground",
+              )}
+            >
+              {tab}
+            </button>
+          ))}
+          {(generating || refreshing) && (
+            <Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
+          )}
+          <div className="flex-1" />
+          {activeSpec && (
+            <button
+              onClick={handleDownload}
+              className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
+            >
+              <Download className="h-3 w-3" />
+            </button>
+          )}
+        </div>
+
+        <div ref={mobileCodeScrollRef} className="flex-1 min-h-0 overflow-auto">
+          {mobileView === "json" ? (
+            <pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
+              {jsonCode}
+            </pre>
+          ) : (
+            <div className="h-full relative bg-neutral-600">
+              {htmlContent ? (
+                <div className="h-full flex justify-center p-5 overflow-auto">
+                  <iframe
+                    srcDoc={htmlContent}
+                    className="w-[620px] h-full border-none bg-white shadow-sm"
+                    title="Email preview"
+                  />
+                </div>
+              ) : (
+                <div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
+                  <FileText className="h-10 w-10" />
+                  <p className="text-sm">Enter a prompt to generate an email</p>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+
+        <div
+          className="border-t border-border p-3 shrink-0 cursor-text"
+          onMouseDown={(e) => {
+            const target = e.target as HTMLElement;
+            if (!target.closest("button") && target.tagName !== "TEXTAREA") {
+              e.preventDefault();
+              mobileInputRef.current?.focus();
+            }
+          }}
+        >
+          <textarea
+            ref={mobileInputRef}
+            value={prompt}
+            onChange={(e) => setPrompt(e.target.value)}
+            onKeyDown={handleKeyDown}
+            placeholder={
+              selection.mode === "scratch"
+                ? "Describe the email you want..."
+                : `Modify the ${currentExample?.label ?? "example"}...`
+            }
+            className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
+            rows={2}
+          />
+          <div className="flex justify-between items-center mt-2">
+            <span className="text-[11px] text-muted-foreground">
+              {selection.mode === "example" && currentExample
+                ? currentExample.label
+                : "scratch"}
+            </span>
+            {generating ? (
+              <button
+                onClick={handleStop}
+                className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
+                aria-label="Stop"
+              >
+                <Square className="h-3 w-3" fill="currentColor" />
+              </button>
+            ) : (
+              <button
+                onClick={handleGenerate}
+                disabled={!prompt.trim()}
+                className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
+                aria-label="Generate"
+              >
+                <ArrowRight className="h-3.5 w-3.5" />
+              </button>
+            )}
+          </div>
+        </div>
+
+        <Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
+          <SheetContent side="left" className="w-80 p-0">
+            <SheetTitle className="sr-only">Examples</SheetTitle>
+            <ScrollArea className="h-full">
+              <div className="p-2 space-y-1">
+                <p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
+                  start
+                </p>
+                <button
+                  onClick={() => select({ mode: "scratch" })}
+                  className={cn(
+                    "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+                    selection.mode === "scratch"
+                      ? "bg-muted text-foreground"
+                      : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+                  )}
+                >
+                  From scratch
+                </button>
+                <p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
+                  examples
+                </p>
+                {examples.map((ex) => (
+                  <button
+                    key={ex.name}
+                    onClick={() =>
+                      select({ mode: "example", exampleName: ex.name })
+                    }
+                    className={cn(
+                      "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+                      selection.mode === "example" &&
+                        selection.exampleName === ex.name
+                        ? "bg-muted text-foreground"
+                        : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+                    )}
+                  >
+                    <span className="font-medium">{ex.label}</span>
+                    <p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
+                      {ex.description}
+                    </p>
+                  </button>
+                ))}
+              </div>
+            </ScrollArea>
+          </SheetContent>
+        </Sheet>
+      </div>
+    </div>
+  );
+}

+ 23 - 0
examples/react-email/components.json

@@ -0,0 +1,23 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "app/globals.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "rtl": false,
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "registries": {}
+}

+ 64 - 0
examples/react-email/components/ui/button.tsx

@@ -0,0 +1,64 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost:
+          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+        icon: "size-9",
+        "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean;
+  }) {
+  const Comp = asChild ? Slot.Root : "button";
+
+  return (
+    <Comp
+      data-slot="button"
+      data-variant={variant}
+      data-size={size}
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  );
+}
+
+export { Button, buttonVariants };

+ 53 - 0
examples/react-email/components/ui/resizable.tsx

@@ -0,0 +1,53 @@
+"use client";
+
+import { GripVerticalIcon } from "lucide-react";
+import * as ResizablePrimitive from "react-resizable-panels";
+
+import { cn } from "@/lib/utils";
+
+function ResizablePanelGroup({
+  className,
+  ...props
+}: ResizablePrimitive.GroupProps) {
+  return (
+    <ResizablePrimitive.Group
+      data-slot="resizable-panel-group"
+      className={cn(
+        "flex h-full w-full aria-[orientation=vertical]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
+  return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
+}
+
+function ResizableHandle({
+  withHandle,
+  className,
+  ...props
+}: ResizablePrimitive.SeparatorProps & {
+  withHandle?: boolean;
+}) {
+  return (
+    <ResizablePrimitive.Separator
+      data-slot="resizable-handle"
+      className={cn(
+        "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
+        className,
+      )}
+      {...props}
+    >
+      {withHandle && (
+        <div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
+          <GripVerticalIcon className="size-2.5" />
+        </div>
+      )}
+    </ResizablePrimitive.Separator>
+  );
+}
+
+export { ResizableHandle, ResizablePanel, ResizablePanelGroup };

+ 58 - 0
examples/react-email/components/ui/scroll-area.tsx

@@ -0,0 +1,58 @@
+"use client";
+
+import * as React from "react";
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+  return (
+    <ScrollAreaPrimitive.Root
+      data-slot="scroll-area"
+      className={cn("relative", className)}
+      {...props}
+    >
+      <ScrollAreaPrimitive.Viewport
+        data-slot="scroll-area-viewport"
+        className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+      >
+        {children}
+      </ScrollAreaPrimitive.Viewport>
+      <ScrollBar />
+      <ScrollAreaPrimitive.Corner />
+    </ScrollAreaPrimitive.Root>
+  );
+}
+
+function ScrollBar({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+  return (
+    <ScrollAreaPrimitive.ScrollAreaScrollbar
+      data-slot="scroll-area-scrollbar"
+      orientation={orientation}
+      className={cn(
+        "flex touch-none p-px transition-colors select-none",
+        orientation === "vertical" &&
+          "h-full w-2.5 border-l border-l-transparent",
+        orientation === "horizontal" &&
+          "h-2.5 flex-col border-t border-t-transparent",
+        className,
+      )}
+      {...props}
+    >
+      <ScrollAreaPrimitive.ScrollAreaThumb
+        data-slot="scroll-area-thumb"
+        className="bg-border relative flex-1 rounded-full"
+      />
+    </ScrollAreaPrimitive.ScrollAreaScrollbar>
+  );
+}
+
+export { ScrollArea, ScrollBar };

+ 28 - 0
examples/react-email/components/ui/separator.tsx

@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { Separator as SeparatorPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  decorative = true,
+  ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+  return (
+    <SeparatorPrimitive.Root
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Separator };

+ 143 - 0
examples/react-email/components/ui/sheet.tsx

@@ -0,0 +1,143 @@
+"use client";
+
+import * as React from "react";
+import { XIcon } from "lucide-react";
+import { Dialog as SheetPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
+  return <SheetPrimitive.Root data-slot="sheet" {...props} />;
+}
+
+function SheetTrigger({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
+  return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
+}
+
+function SheetClose({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Close>) {
+  return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
+}
+
+function SheetPortal({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
+  return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
+}
+
+function SheetOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
+  return (
+    <SheetPrimitive.Overlay
+      data-slot="sheet-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function SheetContent({
+  className,
+  children,
+  side = "right",
+  showCloseButton = true,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Content> & {
+  side?: "top" | "right" | "bottom" | "left";
+  showCloseButton?: boolean;
+}) {
+  return (
+    <SheetPortal>
+      <SheetOverlay />
+      <SheetPrimitive.Content
+        data-slot="sheet-content"
+        className={cn(
+          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+          side === "right" &&
+            "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
+          side === "left" &&
+            "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
+          side === "top" &&
+            "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
+          side === "bottom" &&
+            "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+            <XIcon className="size-4" />
+            <span className="sr-only">Close</span>
+          </SheetPrimitive.Close>
+        )}
+      </SheetPrimitive.Content>
+    </SheetPortal>
+  );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="sheet-header"
+      className={cn("flex flex-col gap-1.5 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="sheet-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function SheetTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Title>) {
+  return (
+    <SheetPrimitive.Title
+      data-slot="sheet-title"
+      className={cn("text-foreground font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function SheetDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Description>) {
+  return (
+    <SheetPrimitive.Description
+      data-slot="sheet-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Sheet,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+};

+ 91 - 0
examples/react-email/components/ui/tabs.tsx

@@ -0,0 +1,91 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+  className,
+  orientation = "horizontal",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      data-orientation={orientation}
+      orientation={orientation}
+      className={cn(
+        "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+const tabsListVariants = cva(
+  "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+  {
+    variants: {
+      variant: {
+        default: "bg-muted",
+        line: "gap-1 bg-transparent",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function TabsList({
+  className,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List> &
+  VariantProps<typeof tabsListVariants>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      data-variant={variant}
+      className={cn(tabsListVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
+        "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 outline-none", className)}
+      {...props}
+    />
+  );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

+ 18 - 0
examples/react-email/components/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Textarea };

+ 8 - 0
examples/react-email/lib/catalog.ts

@@ -0,0 +1,8 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react-email/server";
+import { standardComponentDefinitions } from "@json-render/react-email/catalog";
+
+export const emailCatalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+  actions: {},
+});

+ 1055 - 0
examples/react-email/lib/examples.ts

@@ -0,0 +1,1055 @@
+import type { Spec } from "@json-render/core";
+
+export interface Example {
+  name: string;
+  label: string;
+  description: string;
+  spec: Spec;
+}
+
+const staticUrl = "/static";
+
+export const examples: Example[] = [
+  {
+    name: "vercel-invite",
+    label: "Vercel Invite",
+    description: "Team invitation email with avatars and CTA",
+    spec: {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: {
+          type: "Head",
+          props: {},
+          children: [],
+        },
+        preview: {
+          type: "Preview",
+          props: { text: "Join Alan on Vercel" },
+          children: [],
+        },
+        body: {
+          type: "Body",
+          props: {
+            style: {
+              backgroundColor: "#ffffff",
+              fontFamily:
+                '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif',
+              margin: "0 auto",
+              padding: "0 8px",
+            },
+          },
+          children: ["container"],
+        },
+        container: {
+          type: "Container",
+          props: {
+            style: {
+              maxWidth: "465px",
+              margin: "40px auto",
+              border: "1px solid #eaeaea",
+              borderRadius: "4px",
+              padding: "20px",
+            },
+          },
+          children: [
+            "logo-section",
+            "heading",
+            "greeting-text",
+            "invite-text",
+            "avatar-section",
+            "button-section",
+            "url-text",
+            "invite-link",
+            "hr",
+            "footer-text",
+          ],
+        },
+        "logo-section": {
+          type: "Section",
+          props: { style: { marginTop: "32px" } },
+          children: ["logo"],
+        },
+        logo: {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-logo.png`,
+            width: 40,
+            height: 37,
+            alt: "Vercel",
+            style: { margin: "0 auto", display: "block" },
+          },
+          children: [],
+        },
+        heading: {
+          type: "Heading",
+          props: {
+            text: "Join Enigma on Vercel",
+            as: "h1",
+            style: {
+              color: "#000000",
+              fontSize: "24px",
+              fontWeight: "normal",
+              textAlign: "center",
+              margin: "30px 0",
+              padding: "0",
+            },
+          },
+          children: [],
+        },
+        "greeting-text": {
+          type: "Text",
+          props: {
+            text: "Hello alanturing,",
+            style: {
+              color: "#000000",
+              fontSize: "14px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+        "invite-text": {
+          type: "Text",
+          props: {
+            text: "Alan (alan.turing@example.com) has invited you to the Enigma team on Vercel.",
+            style: {
+              color: "#000000",
+              fontSize: "14px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+        "avatar-section": {
+          type: "Section",
+          props: { style: {} },
+          children: ["avatar-row"],
+        },
+        "avatar-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["user-avatar-col", "arrow-col", "team-avatar-col"],
+        },
+        "user-avatar-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["user-avatar"],
+        },
+        "user-avatar": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-user.png`,
+            width: 64,
+            height: 64,
+            alt: "alanturing",
+            style: { borderRadius: "50%", marginLeft: "auto" },
+          },
+          children: [],
+        },
+        "arrow-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["arrow-img"],
+        },
+        "arrow-img": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-arrow.png`,
+            width: 12,
+            height: 9,
+            alt: "invited to",
+            style: { margin: "0 auto" },
+          },
+          children: [],
+        },
+        "team-avatar-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["team-avatar"],
+        },
+        "team-avatar": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-team.png`,
+            width: 64,
+            height: 64,
+            alt: "Enigma",
+            style: { borderRadius: "50%", marginRight: "auto" },
+          },
+          children: [],
+        },
+        "button-section": {
+          type: "Section",
+          props: {
+            style: {
+              marginTop: "32px",
+              marginBottom: "32px",
+              textAlign: "center",
+            },
+          },
+          children: ["join-button"],
+        },
+        "join-button": {
+          type: "Button",
+          props: {
+            text: "Join the team",
+            href: "https://vercel.com/teams/invite/foo",
+            style: {
+              backgroundColor: "#000000",
+              borderRadius: "4px",
+              color: "#ffffff",
+              fontSize: "12px",
+              fontWeight: "600",
+              textDecoration: "none",
+              textAlign: "center",
+              padding: "12px 20px",
+            },
+          },
+          children: [],
+        },
+        "url-text": {
+          type: "Text",
+          props: {
+            text: "or copy and paste this URL into your browser:",
+            style: {
+              color: "#000000",
+              fontSize: "14px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+        "invite-link": {
+          type: "Link",
+          props: {
+            text: "https://vercel.com/teams/invite/foo",
+            href: "https://vercel.com/teams/invite/foo",
+            style: {
+              color: "#2563eb",
+              textDecoration: "none",
+              fontSize: "14px",
+            },
+          },
+          children: [],
+        },
+        hr: {
+          type: "Hr",
+          props: { style: { borderColor: "#eaeaea", margin: "26px 0" } },
+          children: [],
+        },
+        "footer-text": {
+          type: "Text",
+          props: {
+            text: "This invitation was intended for alanturing. This invite was sent from 204.13.186.218 located in São Paulo, Brazil. If you were not expecting this invitation, you can ignore this email. If you are concerned about your account's safety, please reply to this email to get in touch with us.",
+            style: {
+              color: "#666666",
+              fontSize: "12px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+      },
+    },
+  },
+  {
+    name: "stripe-welcome",
+    label: "Stripe Welcome",
+    description: "Onboarding email with dashboard CTA",
+    spec: {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: {
+          type: "Head",
+          props: {},
+          children: [],
+        },
+        preview: {
+          type: "Preview",
+          props: {
+            text: "You're now ready to make live transactions with Stripe!",
+          },
+          children: [],
+        },
+        body: {
+          type: "Body",
+          props: {
+            style: {
+              backgroundColor: "#f6f9fc",
+              fontFamily:
+                '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif',
+            },
+          },
+          children: ["container"],
+        },
+        container: {
+          type: "Container",
+          props: {
+            style: {
+              backgroundColor: "#ffffff",
+              margin: "0 auto",
+              padding: "20px 0 48px",
+              marginBottom: "64px",
+            },
+          },
+          children: ["content-section"],
+        },
+        "content-section": {
+          type: "Section",
+          props: { style: { padding: "0 48px" } },
+          children: [
+            "logo",
+            "hr1",
+            "text-intro",
+            "text-dashboard",
+            "cta-button",
+            "hr2",
+            "text-docs",
+            "docs-link",
+            "text-api-keys",
+            "text-checklist",
+            "text-support",
+            "text-signoff",
+            "hr3",
+            "footer-text",
+          ],
+        },
+        logo: {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/stripe-logo.png`,
+            width: 49,
+            height: 21,
+            alt: "Stripe",
+            style: null,
+          },
+          children: [],
+        },
+        hr1: {
+          type: "Hr",
+          props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } },
+          children: [],
+        },
+        "text-intro": {
+          type: "Text",
+          props: {
+            text: "Thanks for submitting your account information. You're now ready to make live transactions with Stripe!",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-dashboard": {
+          type: "Text",
+          props: {
+            text: "You can view your payments and a variety of other information about your account right from your dashboard.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "cta-button": {
+          type: "Button",
+          props: {
+            text: "View your Stripe Dashboard",
+            href: "https://dashboard.stripe.com/login",
+            style: {
+              backgroundColor: "#656ee8",
+              borderRadius: "3px",
+              color: "#ffffff",
+              fontSize: "16px",
+              fontWeight: "bold",
+              textDecoration: "none",
+              textAlign: "center",
+              display: "block",
+              padding: "10px",
+            },
+          },
+          children: [],
+        },
+        hr2: {
+          type: "Hr",
+          props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } },
+          children: [],
+        },
+        "text-docs": {
+          type: "Text",
+          props: {
+            text: "If you haven't finished your integration, you might find our docs handy.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "docs-link": {
+          type: "Link",
+          props: {
+            text: "Stripe Documentation — Getting Started",
+            href: "https://docs.stripe.com/dashboard/basics",
+            style: {
+              color: "#556cd6",
+              fontSize: "16px",
+            },
+          },
+          children: [],
+        },
+        "text-api-keys": {
+          type: "Text",
+          props: {
+            text: "Once you're ready to start accepting payments, you'll just need to use your live API keys instead of your test API keys. Your account can simultaneously be used for both test and live requests, so you can continue testing while accepting live payments. Check out our tutorial about account basics.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-checklist": {
+          type: "Text",
+          props: {
+            text: "Finally, we've put together a quick checklist to ensure your website conforms to card network standards.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-support": {
+          type: "Text",
+          props: {
+            text: "We'll be here to help you with any step along the way. You can find answers to most questions and get in touch with us on our support site.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-signoff": {
+          type: "Text",
+          props: {
+            text: "— The Stripe team",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        hr3: {
+          type: "Hr",
+          props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } },
+          children: [],
+        },
+        "footer-text": {
+          type: "Text",
+          props: {
+            text: "Stripe, 354 Oyster Point Blvd, South San Francisco, CA 94080",
+            style: {
+              color: "#8898aa",
+              fontSize: "12px",
+              lineHeight: "16px",
+            },
+          },
+          children: [],
+        },
+      },
+    },
+  },
+  {
+    name: "nike-receipt",
+    label: "Nike Receipt",
+    description: "Order shipment notification with product details",
+    spec: {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: {
+          type: "Head",
+          props: {},
+          children: [],
+        },
+        preview: {
+          type: "Preview",
+          props: {
+            text: "Get your order summary, estimated delivery date and more",
+          },
+          children: [],
+        },
+        body: {
+          type: "Body",
+          props: {
+            style: {
+              backgroundColor: "#ffffff",
+              fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
+            },
+          },
+          children: ["container"],
+        },
+        container: {
+          type: "Container",
+          props: {
+            style: {
+              margin: "10px auto",
+              width: "600px",
+              maxWidth: "100%",
+              border: "1px solid #E5E5E5",
+            },
+          },
+          children: [
+            "tracking-section",
+            "hr1",
+            "hero-section",
+            "hr2",
+            "shipping-section",
+            "hr3",
+            "product-section",
+            "hr4",
+            "order-info-section",
+            "hr5",
+            "footer-section",
+          ],
+        },
+
+        // ── Tracking Section ──
+        "tracking-section": {
+          type: "Section",
+          props: {
+            style: {
+              padding: "22px 40px",
+              backgroundColor: "#F7F7F7",
+            },
+          },
+          children: ["tracking-row"],
+        },
+        "tracking-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["tracking-info-col", "tracking-btn-col"],
+        },
+        "tracking-info-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["tracking-label", "tracking-number"],
+        },
+        "tracking-label": {
+          type: "Text",
+          props: {
+            text: "Tracking Number",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "tracking-number": {
+          type: "Text",
+          props: {
+            text: "1ZV218970300071628",
+            style: {
+              margin: "12px 0 0",
+              fontSize: "14px",
+              lineHeight: "1.4",
+              fontWeight: "500",
+              color: "#6F6F6F",
+            },
+          },
+          children: [],
+        },
+        "tracking-btn-col": {
+          type: "Column",
+          props: { style: { textAlign: "right" } },
+          children: ["tracking-link"],
+        },
+        "tracking-link": {
+          type: "Link",
+          props: {
+            text: "Track Package",
+            href: "https://www.nike.com/orders",
+            style: {
+              border: "1px solid #929292",
+              fontSize: "16px",
+              textDecoration: "none",
+              padding: "10px 0",
+              width: "220px",
+              display: "block",
+              textAlign: "center",
+              fontWeight: "500",
+              color: "#000000",
+            },
+          },
+          children: [],
+        },
+        hr1: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Hero Section ──
+        "hero-section": {
+          type: "Section",
+          props: {
+            style: {
+              padding: "40px 74px",
+              textAlign: "center",
+            },
+          },
+          children: [
+            "nike-logo",
+            "hero-heading",
+            "hero-text",
+            "hero-text-payment",
+          ],
+        },
+        "nike-logo": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/nike-logo.png`,
+            width: 66,
+            height: 22,
+            alt: "Nike",
+            style: { margin: "0 auto", display: "block" },
+          },
+          children: [],
+        },
+        "hero-heading": {
+          type: "Heading",
+          props: {
+            text: "It's On Its Way.",
+            as: "h1",
+            style: {
+              fontSize: "32px",
+              lineHeight: "1.3",
+              fontWeight: "bold",
+              textAlign: "center",
+              letterSpacing: "-1px",
+            },
+          },
+          children: [],
+        },
+        "hero-text": {
+          type: "Text",
+          props: {
+            text: "Your order is on its way. Use the link above to track its progress.",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        "hero-text-payment": {
+          type: "Text",
+          props: {
+            text: "We've also charged your payment method for the cost of your order and will be removing any authorization holds. For payment details, please visit your Orders page on Nike.com or in the Nike app.",
+            style: {
+              margin: "24px 0 0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        hr2: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Shipping Section ──
+        "shipping-section": {
+          type: "Section",
+          props: { style: { padding: "22px 40px" } },
+          children: ["shipping-label", "shipping-address"],
+        },
+        "shipping-label": {
+          type: "Text",
+          props: {
+            text: "Shipping to: Alan Turing",
+            style: {
+              margin: "0",
+              fontSize: "15px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "shipping-address": {
+          type: "Text",
+          props: {
+            text: "2125 Chestnut St, San Francisco, CA 94123",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        hr3: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Product Section ──
+        "product-section": {
+          type: "Section",
+          props: { style: { padding: "40px" } },
+          children: ["product-row"],
+        },
+        "product-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["product-img-col", "product-details-col"],
+        },
+        "product-img-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["product-img"],
+        },
+        "product-img": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/nike-product.png`,
+            alt: "Brazil 2022/23 Stadium Away Women's Nike Dri-FIT Soccer Jersey",
+            width: 260,
+            height: null,
+            style: { float: "left" },
+          },
+          children: [],
+        },
+        "product-details-col": {
+          type: "Column",
+          props: {
+            style: { verticalAlign: "top", paddingLeft: "12px" },
+          },
+          children: ["product-name", "product-size"],
+        },
+        "product-name": {
+          type: "Text",
+          props: {
+            text: "Brazil 2022/23 Stadium Away Women's Nike Dri-FIT Soccer Jersey",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        "product-size": {
+          type: "Text",
+          props: {
+            text: "Size L (12–14)",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        hr4: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Order Info Section ──
+        "order-info-section": {
+          type: "Section",
+          props: { style: { padding: "22px 40px" } },
+          children: ["order-meta-row", "order-status-row"],
+        },
+        "order-meta-row": {
+          type: "Row",
+          props: { style: { marginBottom: "40px" } },
+          children: ["order-number-col", "order-date-col"],
+        },
+        "order-number-col": {
+          type: "Column",
+          props: { style: { width: "170px" } },
+          children: ["order-number-label", "order-number-value"],
+        },
+        "order-number-label": {
+          type: "Text",
+          props: {
+            text: "Order Number",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "order-number-value": {
+          type: "Text",
+          props: {
+            text: "C0106373851",
+            style: {
+              margin: "12px 0 0",
+              fontSize: "14px",
+              lineHeight: "1.4",
+              fontWeight: "500",
+              color: "#6F6F6F",
+            },
+          },
+          children: [],
+        },
+        "order-date-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["order-date-label", "order-date-value"],
+        },
+        "order-date-label": {
+          type: "Text",
+          props: {
+            text: "Order Date",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "order-date-value": {
+          type: "Text",
+          props: {
+            text: "Sep 22, 2022",
+            style: {
+              margin: "12px 0 0",
+              fontSize: "14px",
+              lineHeight: "1.4",
+              fontWeight: "500",
+              color: "#6F6F6F",
+            },
+          },
+          children: [],
+        },
+        "order-status-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["order-status-col"],
+        },
+        "order-status-col": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["order-status-link"],
+        },
+        "order-status-link": {
+          type: "Link",
+          props: {
+            text: "Order Status",
+            href: "https://www.nike.com/orders",
+            style: {
+              border: "1px solid #929292",
+              fontSize: "16px",
+              textDecoration: "none",
+              padding: "10px 0",
+              width: "220px",
+              display: "block",
+              textAlign: "center",
+              fontWeight: "500",
+              color: "#000000",
+              margin: "0 auto",
+            },
+          },
+          children: [],
+        },
+        hr5: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Footer Section ──
+        "footer-section": {
+          type: "Section",
+          props: { style: { padding: "22px 0", textAlign: "center" } },
+          children: [
+            "footer-brand",
+            "footer-nav-row",
+            "footer-contact",
+            "footer-copyright",
+            "footer-address",
+          ],
+        },
+        "footer-brand": {
+          type: "Text",
+          props: {
+            text: "Nike.com",
+            style: {
+              fontSize: "32px",
+              lineHeight: "1.3",
+              fontWeight: "bold",
+              textAlign: "center",
+              letterSpacing: "-1px",
+            },
+          },
+          children: [],
+        },
+        "footer-nav-row": {
+          type: "Row",
+          props: { style: { width: "370px", margin: "0 auto" } },
+          children: [
+            "footer-nav-men",
+            "footer-nav-women",
+            "footer-nav-kids",
+            "footer-nav-customize",
+          ],
+        },
+        "footer-nav-men": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-men"],
+        },
+        "link-men": {
+          type: "Link",
+          props: {
+            text: "Men",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-nav-women": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-women"],
+        },
+        "link-women": {
+          type: "Link",
+          props: {
+            text: "Women",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-nav-kids": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-kids"],
+        },
+        "link-kids": {
+          type: "Link",
+          props: {
+            text: "Kids",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-nav-customize": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-customize"],
+        },
+        "link-customize": {
+          type: "Link",
+          props: {
+            text: "Customize",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-contact": {
+          type: "Text",
+          props: {
+            text: "Please contact us if you have any questions. (If you reply to this email, we won't be able to see it.)",
+            style: {
+              margin: "0",
+              color: "#AFAFAF",
+              fontSize: "13px",
+              textAlign: "center",
+              padding: "30px 0",
+            },
+          },
+          children: [],
+        },
+        "footer-copyright": {
+          type: "Text",
+          props: {
+            text: "© 2022 Nike, Inc. All Rights Reserved.",
+            style: {
+              margin: "0",
+              color: "#AFAFAF",
+              fontSize: "13px",
+              textAlign: "center",
+            },
+          },
+          children: [],
+        },
+        "footer-address": {
+          type: "Text",
+          props: {
+            text: "NIKE, INC. One Bowerman Drive, Beaverton, Oregon 97005, USA.",
+            style: {
+              margin: "0",
+              color: "#AFAFAF",
+              fontSize: "13px",
+              textAlign: "center",
+            },
+          },
+          children: [],
+        },
+      },
+    },
+  },
+];

+ 6 - 0
examples/react-email/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 6 - 0
examples/react-email/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 11 - 0
examples/react-email/next.config.ts

@@ -0,0 +1,11 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  serverExternalPackages: [
+    "@react-email/components",
+    "@react-email/render",
+    "@json-render/react-email",
+  ],
+};
+
+export default nextConfig;

+ 39 - 0
examples/react-email/package.json

@@ -0,0 +1,39 @@
+{
+  "name": "example-react-email",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "portless react-email-demo.json-render next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.52",
+    "@json-render/core": "workspace:*",
+    "@json-render/react-email": "workspace:*",
+    "@tailwindcss/postcss": "^4.1.18",
+    "ai": "6.0.94",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.575.0",
+    "next": "16.1.6",
+    "radix-ui": "^1.4.3",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "react-resizable-panels": "^4.4.1",
+    "tailwind-merge": "^3.5.0",
+    "tailwindcss": "^4.1.18",
+    "zod": "4.3.5"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "shadcn": "^3.8.5",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 8 - 0
examples/react-email/postcss.config.mjs

@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};
+
+export default config;

BIN
examples/react-email/public/static/nike-logo.png


BIN
examples/react-email/public/static/nike-product.png


BIN
examples/react-email/public/static/stripe-logo.png


BIN
examples/react-email/public/static/vercel-arrow.png


BIN
examples/react-email/public/static/vercel-logo.png


BIN
examples/react-email/public/static/vercel-team.png


BIN
examples/react-email/public/static/vercel-user.png


+ 11 - 0
examples/react-email/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 147 - 0
packages/react-email/README.md

@@ -0,0 +1,147 @@
+# @json-render/react-email
+
+React Email renderer for `@json-render/core`. Generate HTML and plain-text emails from JSON specs using `@react-email/components` and `@react-email/render`.
+
+## Install
+
+```bash
+npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
+```
+
+## Quick Start
+
+### Render a spec to HTML
+
+```typescript
+import { renderToHtml } from "@json-render/react-email";
+import type { Spec } from "@json-render/core";
+
+const spec: Spec = {
+  root: "html-1",
+  elements: {
+    "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
+    "head-1": { type: "Head", props: {}, children: [] },
+    "body-1": {
+      type: "Body",
+      props: { style: { backgroundColor: "#f6f9fc" } },
+      children: ["container-1"],
+    },
+    "container-1": {
+      type: "Container",
+      props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
+      children: ["heading-1", "text-1"],
+    },
+    "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
+    "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
+  },
+};
+
+const html = await renderToHtml(spec);
+```
+
+### With a custom catalog
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema, defineRegistry, renderToHtml } from "@json-render/react-email";
+import { standardComponentDefinitions } from "@json-render/react-email/catalog";
+import { Container, Heading, Text } from "@react-email/components";
+import { z } from "zod";
+
+const catalog = defineCatalog(schema, {
+  components: {
+    ...standardComponentDefinitions,
+    Alert: {
+      props: z.object({
+        message: z.string(),
+        variant: z.enum(["info", "success", "warning"]).nullable(),
+      }),
+      slots: [],
+      description: "A highlighted message block",
+    },
+  },
+  actions: {},
+});
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Alert: ({ props }) => (
+      <Container style={{ padding: 16, backgroundColor: "#eff6ff", borderRadius: 8 }}>
+        <Text style={{ margin: 0 }}>{props.message}</Text>
+      </Container>
+    ),
+  },
+});
+
+const html = await renderToHtml(spec, { registry });
+```
+
+## Standard Components
+
+### Document structure
+
+| Component | Description |
+|-----------|-------------|
+| `Html` | Top-level email wrapper. Must be the root element. |
+| `Head` | Email head section. |
+| `Body` | Email body wrapper. |
+
+### Layout
+
+| Component | Description |
+|-----------|-------------|
+| `Container` | Constrains content width. |
+| `Section` | Groups related content. |
+| `Row` | Horizontal layout row. |
+| `Column` | Column within a Row. |
+
+### Content
+
+| Component | Description |
+|-----------|-------------|
+| `Heading` | Heading text (h1–h6). |
+| `Text` | Body text paragraph. |
+| `Link` | Hyperlink. |
+| `Button` | Call-to-action button. |
+| `Image` | Image from URL. |
+| `Hr` | Horizontal rule. |
+
+### Utility
+
+| Component | Description |
+|-----------|-------------|
+| `Preview` | Inbox preview text. |
+| `Markdown` | Markdown content as email-safe HTML. |
+
+## Server-Side APIs
+
+```typescript
+import { renderToHtml, renderToPlainText } from "@json-render/react-email";
+
+const html = await renderToHtml(spec);
+const plainText = await renderToPlainText(spec);
+```
+
+Both accept an optional second argument with:
+
+- `registry` — Custom component registry (merged with standard components)
+- `includeStandard` — Include built-in standard components (default: `true`)
+- `state` — Initial state for `$state` / `$cond` dynamic prop resolution
+
+## Server-Safe Import
+
+Import schema and catalog definitions without pulling in React or `@react-email/components`:
+
+```typescript
+import { schema, standardComponentDefinitions } from "@json-render/react-email/server";
+```
+
+## Documentation
+
+Full API reference: [json-render.dev/docs/api/react-email](https://json-render.dev/docs/api/react-email).
+
+Example app: [examples/react-email](https://github.com/vercel-labs/json-render/tree/main/examples/react-email).
+
+## License
+
+Apache-2.0

+ 78 - 0
packages/react-email/package.json

@@ -0,0 +1,78 @@
+{
+  "name": "@json-render/react-email",
+  "version": "0.8.0",
+  "license": "Apache-2.0",
+  "description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
+  "keywords": [
+    "json",
+    "email",
+    "react-email",
+    "ai",
+    "generative-ui",
+    "llm",
+    "renderer",
+    "html"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/react-email"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    },
+    "./server": {
+      "types": "./dist/server.d.ts",
+      "import": "./dist/server.mjs",
+      "require": "./dist/server.js"
+    },
+    "./catalog": {
+      "types": "./dist/catalog.d.ts",
+      "import": "./dist/catalog.mjs",
+      "require": "./dist/catalog.js"
+    },
+    "./render": {
+      "types": "./dist/render.d.ts",
+      "import": "./dist/render.mjs",
+      "require": "./dist/render.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@react-email/components": "^1.0.8",
+    "@react-email/render": "^2.0.4"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "@types/react": "19.2.3",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "zod": "^4.0.0"
+  },
+  "peerDependencies": {
+    "react": "^19.0.0",
+    "zod": "^4.0.0"
+  }
+}

+ 1057 - 0
packages/react-email/src/__fixtures__/examples.ts

@@ -0,0 +1,1057 @@
+import type { Spec } from "@json-render/core";
+
+export interface Example {
+  name: string;
+  label: string;
+  description: string;
+  spec: Spec;
+}
+
+// Images sourced from the react-email demo; URLs are only used for spec
+// structure validation in tests and are not fetched at runtime.
+const staticUrl = "https://react-email-demo-bdj5iju9r-resend.vercel.app/static";
+
+export const examples: Example[] = [
+  {
+    name: "vercel-invite",
+    label: "Vercel Invite",
+    description: "Team invitation email with avatars and CTA",
+    spec: {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: {
+          type: "Head",
+          props: {},
+          children: [],
+        },
+        preview: {
+          type: "Preview",
+          props: { text: "Join Alan on Vercel" },
+          children: [],
+        },
+        body: {
+          type: "Body",
+          props: {
+            style: {
+              backgroundColor: "#ffffff",
+              fontFamily:
+                '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif',
+              margin: "0 auto",
+              padding: "0 8px",
+            },
+          },
+          children: ["container"],
+        },
+        container: {
+          type: "Container",
+          props: {
+            style: {
+              maxWidth: "465px",
+              margin: "40px auto",
+              border: "1px solid #eaeaea",
+              borderRadius: "4px",
+              padding: "20px",
+            },
+          },
+          children: [
+            "logo-section",
+            "heading",
+            "greeting-text",
+            "invite-text",
+            "avatar-section",
+            "button-section",
+            "url-text",
+            "invite-link",
+            "hr",
+            "footer-text",
+          ],
+        },
+        "logo-section": {
+          type: "Section",
+          props: { style: { marginTop: "32px" } },
+          children: ["logo"],
+        },
+        logo: {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-logo.png`,
+            width: 40,
+            height: 37,
+            alt: "Vercel",
+            style: { margin: "0 auto", display: "block" },
+          },
+          children: [],
+        },
+        heading: {
+          type: "Heading",
+          props: {
+            text: "Join Enigma on Vercel",
+            as: "h1",
+            style: {
+              color: "#000000",
+              fontSize: "24px",
+              fontWeight: "normal",
+              textAlign: "center",
+              margin: "30px 0",
+              padding: "0",
+            },
+          },
+          children: [],
+        },
+        "greeting-text": {
+          type: "Text",
+          props: {
+            text: "Hello alanturing,",
+            style: {
+              color: "#000000",
+              fontSize: "14px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+        "invite-text": {
+          type: "Text",
+          props: {
+            text: "Alan (alan.turing@example.com) has invited you to the Enigma team on Vercel.",
+            style: {
+              color: "#000000",
+              fontSize: "14px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+        "avatar-section": {
+          type: "Section",
+          props: { style: {} },
+          children: ["avatar-row"],
+        },
+        "avatar-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["user-avatar-col", "arrow-col", "team-avatar-col"],
+        },
+        "user-avatar-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["user-avatar"],
+        },
+        "user-avatar": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-user.png`,
+            width: 64,
+            height: 64,
+            alt: "alanturing",
+            style: { borderRadius: "50%", marginLeft: "auto" },
+          },
+          children: [],
+        },
+        "arrow-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["arrow-img"],
+        },
+        "arrow-img": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-arrow.png`,
+            width: 12,
+            height: 9,
+            alt: "invited to",
+            style: { margin: "0 auto" },
+          },
+          children: [],
+        },
+        "team-avatar-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["team-avatar"],
+        },
+        "team-avatar": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/vercel-team.png`,
+            width: 64,
+            height: 64,
+            alt: "Enigma",
+            style: { borderRadius: "50%", marginRight: "auto" },
+          },
+          children: [],
+        },
+        "button-section": {
+          type: "Section",
+          props: {
+            style: {
+              marginTop: "32px",
+              marginBottom: "32px",
+              textAlign: "center",
+            },
+          },
+          children: ["join-button"],
+        },
+        "join-button": {
+          type: "Button",
+          props: {
+            text: "Join the team",
+            href: "https://vercel.com/teams/invite/foo",
+            style: {
+              backgroundColor: "#000000",
+              borderRadius: "4px",
+              color: "#ffffff",
+              fontSize: "12px",
+              fontWeight: "600",
+              textDecoration: "none",
+              textAlign: "center",
+              padding: "12px 20px",
+            },
+          },
+          children: [],
+        },
+        "url-text": {
+          type: "Text",
+          props: {
+            text: "or copy and paste this URL into your browser:",
+            style: {
+              color: "#000000",
+              fontSize: "14px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+        "invite-link": {
+          type: "Link",
+          props: {
+            text: "https://vercel.com/teams/invite/foo",
+            href: "https://vercel.com/teams/invite/foo",
+            style: {
+              color: "#2563eb",
+              textDecoration: "none",
+              fontSize: "14px",
+            },
+          },
+          children: [],
+        },
+        hr: {
+          type: "Hr",
+          props: { style: { borderColor: "#eaeaea", margin: "26px 0" } },
+          children: [],
+        },
+        "footer-text": {
+          type: "Text",
+          props: {
+            text: "This invitation was intended for alanturing. This invite was sent from 204.13.186.218 located in São Paulo, Brazil. If you were not expecting this invitation, you can ignore this email. If you are concerned about your account's safety, please reply to this email to get in touch with us.",
+            style: {
+              color: "#666666",
+              fontSize: "12px",
+              lineHeight: "24px",
+            },
+          },
+          children: [],
+        },
+      },
+    },
+  },
+  {
+    name: "stripe-welcome",
+    label: "Stripe Welcome",
+    description: "Onboarding email with dashboard CTA",
+    spec: {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: {
+          type: "Head",
+          props: {},
+          children: [],
+        },
+        preview: {
+          type: "Preview",
+          props: {
+            text: "You're now ready to make live transactions with Stripe!",
+          },
+          children: [],
+        },
+        body: {
+          type: "Body",
+          props: {
+            style: {
+              backgroundColor: "#f6f9fc",
+              fontFamily:
+                '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif',
+            },
+          },
+          children: ["container"],
+        },
+        container: {
+          type: "Container",
+          props: {
+            style: {
+              backgroundColor: "#ffffff",
+              margin: "0 auto",
+              padding: "20px 0 48px",
+              marginBottom: "64px",
+            },
+          },
+          children: ["content-section"],
+        },
+        "content-section": {
+          type: "Section",
+          props: { style: { padding: "0 48px" } },
+          children: [
+            "logo",
+            "hr1",
+            "text-intro",
+            "text-dashboard",
+            "cta-button",
+            "hr2",
+            "text-docs",
+            "docs-link",
+            "text-api-keys",
+            "text-checklist",
+            "text-support",
+            "text-signoff",
+            "hr3",
+            "footer-text",
+          ],
+        },
+        logo: {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/stripe-logo.png`,
+            width: 49,
+            height: 21,
+            alt: "Stripe",
+            style: null,
+          },
+          children: [],
+        },
+        hr1: {
+          type: "Hr",
+          props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } },
+          children: [],
+        },
+        "text-intro": {
+          type: "Text",
+          props: {
+            text: "Thanks for submitting your account information. You're now ready to make live transactions with Stripe!",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-dashboard": {
+          type: "Text",
+          props: {
+            text: "You can view your payments and a variety of other information about your account right from your dashboard.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "cta-button": {
+          type: "Button",
+          props: {
+            text: "View your Stripe Dashboard",
+            href: "https://dashboard.stripe.com/login",
+            style: {
+              backgroundColor: "#656ee8",
+              borderRadius: "3px",
+              color: "#ffffff",
+              fontSize: "16px",
+              fontWeight: "bold",
+              textDecoration: "none",
+              textAlign: "center",
+              display: "block",
+              padding: "10px",
+            },
+          },
+          children: [],
+        },
+        hr2: {
+          type: "Hr",
+          props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } },
+          children: [],
+        },
+        "text-docs": {
+          type: "Text",
+          props: {
+            text: "If you haven't finished your integration, you might find our docs handy.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "docs-link": {
+          type: "Link",
+          props: {
+            text: "Stripe Documentation — Getting Started",
+            href: "https://docs.stripe.com/dashboard/basics",
+            style: {
+              color: "#556cd6",
+              fontSize: "16px",
+            },
+          },
+          children: [],
+        },
+        "text-api-keys": {
+          type: "Text",
+          props: {
+            text: "Once you're ready to start accepting payments, you'll just need to use your live API keys instead of your test API keys. Your account can simultaneously be used for both test and live requests, so you can continue testing while accepting live payments. Check out our tutorial about account basics.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-checklist": {
+          type: "Text",
+          props: {
+            text: "Finally, we've put together a quick checklist to ensure your website conforms to card network standards.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-support": {
+          type: "Text",
+          props: {
+            text: "We'll be here to help you with any step along the way. You can find answers to most questions and get in touch with us on our support site.",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        "text-signoff": {
+          type: "Text",
+          props: {
+            text: "— The Stripe team",
+            style: {
+              color: "#525f7f",
+              fontSize: "16px",
+              lineHeight: "24px",
+              textAlign: "left",
+            },
+          },
+          children: [],
+        },
+        hr3: {
+          type: "Hr",
+          props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } },
+          children: [],
+        },
+        "footer-text": {
+          type: "Text",
+          props: {
+            text: "Stripe, 354 Oyster Point Blvd, South San Francisco, CA 94080",
+            style: {
+              color: "#8898aa",
+              fontSize: "12px",
+              lineHeight: "16px",
+            },
+          },
+          children: [],
+        },
+      },
+    },
+  },
+  {
+    name: "nike-receipt",
+    label: "Nike Receipt",
+    description: "Order shipment notification with product details",
+    spec: {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: {
+          type: "Head",
+          props: {},
+          children: [],
+        },
+        preview: {
+          type: "Preview",
+          props: {
+            text: "Get your order summary, estimated delivery date and more",
+          },
+          children: [],
+        },
+        body: {
+          type: "Body",
+          props: {
+            style: {
+              backgroundColor: "#ffffff",
+              fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
+            },
+          },
+          children: ["container"],
+        },
+        container: {
+          type: "Container",
+          props: {
+            style: {
+              margin: "10px auto",
+              width: "600px",
+              maxWidth: "100%",
+              border: "1px solid #E5E5E5",
+            },
+          },
+          children: [
+            "tracking-section",
+            "hr1",
+            "hero-section",
+            "hr2",
+            "shipping-section",
+            "hr3",
+            "product-section",
+            "hr4",
+            "order-info-section",
+            "hr5",
+            "footer-section",
+          ],
+        },
+
+        // ── Tracking Section ──
+        "tracking-section": {
+          type: "Section",
+          props: {
+            style: {
+              padding: "22px 40px",
+              backgroundColor: "#F7F7F7",
+            },
+          },
+          children: ["tracking-row"],
+        },
+        "tracking-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["tracking-info-col", "tracking-btn-col"],
+        },
+        "tracking-info-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["tracking-label", "tracking-number"],
+        },
+        "tracking-label": {
+          type: "Text",
+          props: {
+            text: "Tracking Number",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "tracking-number": {
+          type: "Text",
+          props: {
+            text: "1ZV218970300071628",
+            style: {
+              margin: "12px 0 0",
+              fontSize: "14px",
+              lineHeight: "1.4",
+              fontWeight: "500",
+              color: "#6F6F6F",
+            },
+          },
+          children: [],
+        },
+        "tracking-btn-col": {
+          type: "Column",
+          props: { style: { textAlign: "right" } },
+          children: ["tracking-link"],
+        },
+        "tracking-link": {
+          type: "Link",
+          props: {
+            text: "Track Package",
+            href: "https://www.nike.com/orders",
+            style: {
+              border: "1px solid #929292",
+              fontSize: "16px",
+              textDecoration: "none",
+              padding: "10px 0",
+              width: "220px",
+              display: "block",
+              textAlign: "center",
+              fontWeight: "500",
+              color: "#000000",
+            },
+          },
+          children: [],
+        },
+        hr1: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Hero Section ──
+        "hero-section": {
+          type: "Section",
+          props: {
+            style: {
+              padding: "40px 74px",
+              textAlign: "center",
+            },
+          },
+          children: [
+            "nike-logo",
+            "hero-heading",
+            "hero-text",
+            "hero-text-payment",
+          ],
+        },
+        "nike-logo": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/nike-logo.png`,
+            width: 66,
+            height: 22,
+            alt: "Nike",
+            style: { margin: "0 auto", display: "block" },
+          },
+          children: [],
+        },
+        "hero-heading": {
+          type: "Heading",
+          props: {
+            text: "It's On Its Way.",
+            as: "h1",
+            style: {
+              fontSize: "32px",
+              lineHeight: "1.3",
+              fontWeight: "bold",
+              textAlign: "center",
+              letterSpacing: "-1px",
+            },
+          },
+          children: [],
+        },
+        "hero-text": {
+          type: "Text",
+          props: {
+            text: "Your order is on its way. Use the link above to track its progress.",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        "hero-text-payment": {
+          type: "Text",
+          props: {
+            text: "We've also charged your payment method for the cost of your order and will be removing any authorization holds. For payment details, please visit your Orders page on Nike.com or in the Nike app.",
+            style: {
+              margin: "24px 0 0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        hr2: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Shipping Section ──
+        "shipping-section": {
+          type: "Section",
+          props: { style: { padding: "22px 40px" } },
+          children: ["shipping-label", "shipping-address"],
+        },
+        "shipping-label": {
+          type: "Text",
+          props: {
+            text: "Shipping to: Alan Turing",
+            style: {
+              margin: "0",
+              fontSize: "15px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "shipping-address": {
+          type: "Text",
+          props: {
+            text: "2125 Chestnut St, San Francisco, CA 94123",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        hr3: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Product Section ──
+        "product-section": {
+          type: "Section",
+          props: { style: { padding: "40px" } },
+          children: ["product-row"],
+        },
+        "product-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["product-img-col", "product-details-col"],
+        },
+        "product-img-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["product-img"],
+        },
+        "product-img": {
+          type: "Image",
+          props: {
+            src: `${staticUrl}/nike-product.png`,
+            alt: "Brazil 2022/23 Stadium Away Women's Nike Dri-FIT Soccer Jersey",
+            width: 260,
+            height: null,
+            style: { float: "left" },
+          },
+          children: [],
+        },
+        "product-details-col": {
+          type: "Column",
+          props: {
+            style: { verticalAlign: "top", paddingLeft: "12px" },
+          },
+          children: ["product-name", "product-size"],
+        },
+        "product-name": {
+          type: "Text",
+          props: {
+            text: "Brazil 2022/23 Stadium Away Women's Nike Dri-FIT Soccer Jersey",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        "product-size": {
+          type: "Text",
+          props: {
+            text: "Size L (12–14)",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              color: "#747474",
+              fontWeight: "500",
+            },
+          },
+          children: [],
+        },
+        hr4: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Order Info Section ──
+        "order-info-section": {
+          type: "Section",
+          props: { style: { padding: "22px 40px" } },
+          children: ["order-meta-row", "order-status-row"],
+        },
+        "order-meta-row": {
+          type: "Row",
+          props: { style: { marginBottom: "40px" } },
+          children: ["order-number-col", "order-date-col"],
+        },
+        "order-number-col": {
+          type: "Column",
+          props: { style: { width: "170px" } },
+          children: ["order-number-label", "order-number-value"],
+        },
+        "order-number-label": {
+          type: "Text",
+          props: {
+            text: "Order Number",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "order-number-value": {
+          type: "Text",
+          props: {
+            text: "C0106373851",
+            style: {
+              margin: "12px 0 0",
+              fontSize: "14px",
+              lineHeight: "1.4",
+              fontWeight: "500",
+              color: "#6F6F6F",
+            },
+          },
+          children: [],
+        },
+        "order-date-col": {
+          type: "Column",
+          props: { style: {} },
+          children: ["order-date-label", "order-date-value"],
+        },
+        "order-date-label": {
+          type: "Text",
+          props: {
+            text: "Order Date",
+            style: {
+              margin: "0",
+              fontSize: "14px",
+              lineHeight: "2",
+              fontWeight: "bold",
+            },
+          },
+          children: [],
+        },
+        "order-date-value": {
+          type: "Text",
+          props: {
+            text: "Sep 22, 2022",
+            style: {
+              margin: "12px 0 0",
+              fontSize: "14px",
+              lineHeight: "1.4",
+              fontWeight: "500",
+              color: "#6F6F6F",
+            },
+          },
+          children: [],
+        },
+        "order-status-row": {
+          type: "Row",
+          props: { style: {} },
+          children: ["order-status-col"],
+        },
+        "order-status-col": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["order-status-link"],
+        },
+        "order-status-link": {
+          type: "Link",
+          props: {
+            text: "Order Status",
+            href: "https://www.nike.com/orders",
+            style: {
+              border: "1px solid #929292",
+              fontSize: "16px",
+              textDecoration: "none",
+              padding: "10px 0",
+              width: "220px",
+              display: "block",
+              textAlign: "center",
+              fontWeight: "500",
+              color: "#000000",
+              margin: "0 auto",
+            },
+          },
+          children: [],
+        },
+        hr5: {
+          type: "Hr",
+          props: { style: { borderColor: "#E5E5E5", margin: "0" } },
+          children: [],
+        },
+
+        // ── Footer Section ──
+        "footer-section": {
+          type: "Section",
+          props: { style: { padding: "22px 0", textAlign: "center" } },
+          children: [
+            "footer-brand",
+            "footer-nav-row",
+            "footer-contact",
+            "footer-copyright",
+            "footer-address",
+          ],
+        },
+        "footer-brand": {
+          type: "Text",
+          props: {
+            text: "Nike.com",
+            style: {
+              fontSize: "32px",
+              lineHeight: "1.3",
+              fontWeight: "bold",
+              textAlign: "center",
+              letterSpacing: "-1px",
+            },
+          },
+          children: [],
+        },
+        "footer-nav-row": {
+          type: "Row",
+          props: { style: { width: "370px", margin: "0 auto" } },
+          children: [
+            "footer-nav-men",
+            "footer-nav-women",
+            "footer-nav-kids",
+            "footer-nav-customize",
+          ],
+        },
+        "footer-nav-men": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-men"],
+        },
+        "link-men": {
+          type: "Link",
+          props: {
+            text: "Men",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-nav-women": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-women"],
+        },
+        "link-women": {
+          type: "Link",
+          props: {
+            text: "Women",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-nav-kids": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-kids"],
+        },
+        "link-kids": {
+          type: "Link",
+          props: {
+            text: "Kids",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-nav-customize": {
+          type: "Column",
+          props: { style: { textAlign: "center" } },
+          children: ["link-customize"],
+        },
+        "link-customize": {
+          type: "Link",
+          props: {
+            text: "Customize",
+            href: "https://www.nike.com/",
+            style: { fontWeight: "500", color: "#000000" },
+          },
+          children: [],
+        },
+        "footer-contact": {
+          type: "Text",
+          props: {
+            text: "Please contact us if you have any questions. (If you reply to this email, we won't be able to see it.)",
+            style: {
+              margin: "0",
+              color: "#AFAFAF",
+              fontSize: "13px",
+              textAlign: "center",
+              padding: "30px 0",
+            },
+          },
+          children: [],
+        },
+        "footer-copyright": {
+          type: "Text",
+          props: {
+            text: "© 2022 Nike, Inc. All Rights Reserved.",
+            style: {
+              margin: "0",
+              color: "#AFAFAF",
+              fontSize: "13px",
+              textAlign: "center",
+            },
+          },
+          children: [],
+        },
+        "footer-address": {
+          type: "Text",
+          props: {
+            text: "NIKE, INC. One Bowerman Drive, Beaverton, Oregon 97005, USA.",
+            style: {
+              margin: "0",
+              color: "#AFAFAF",
+              fontSize: "13px",
+              textAlign: "center",
+            },
+          },
+          children: [],
+        },
+      },
+    },
+  },
+];

+ 41 - 0
packages/react-email/src/catalog-types.ts

@@ -0,0 +1,41 @@
+import type { ReactNode } from "react";
+import type {
+  Catalog,
+  InferCatalogComponents,
+  InferComponentProps,
+  StateModel,
+} from "@json-render/core";
+
+export type { StateModel };
+
+// =============================================================================
+// State Types
+// =============================================================================
+
+export type SetState = (
+  updater: (prev: Record<string, unknown>) => Record<string, unknown>,
+) => void;
+
+// =============================================================================
+// Component Types
+// =============================================================================
+
+export interface ComponentContext<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> {
+  props: InferComponentProps<C, K>;
+  children?: ReactNode;
+  emit: (event: string) => void;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+
+export type ComponentFn<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> = (ctx: ComponentContext<C, K>) => ReactNode;
+
+export type Components<C extends Catalog> = {
+  [K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
+};

+ 219 - 0
packages/react-email/src/catalog.ts

@@ -0,0 +1,219 @@
+import { z } from "zod";
+
+const styleSchema = z.record(z.string(), z.any()).nullable();
+
+/**
+ * Standard component definitions for React Email catalogs.
+ *
+ * These define the available email components with their Zod prop schemas.
+ * All components render using @react-email/components primitives.
+ */
+export const standardComponentDefinitions = {
+  // ==========================================================================
+  // Document Structure
+  // ==========================================================================
+
+  Html: {
+    props: z.object({
+      lang: z.string().nullable(),
+      dir: z.enum(["ltr", "rtl"]).nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "Top-level HTML email wrapper. Must be the root element. Children should include Head and Body.",
+    example: { lang: "en", dir: "ltr" },
+  },
+
+  Head: {
+    props: z.object({}),
+    slots: ["default"],
+    description:
+      "Email head section. Place inside Html. Can contain metadata but typically left empty.",
+    example: {},
+  },
+
+  Body: {
+    props: z.object({
+      style: styleSchema,
+    }),
+    slots: ["default"],
+    description:
+      "Email body wrapper. Place inside Html after Head. Contains all visible email content.",
+    example: { style: { backgroundColor: "#f6f9fc" } },
+  },
+
+  Container: {
+    props: z.object({
+      style: styleSchema,
+    }),
+    slots: ["default"],
+    description:
+      "Constrains content width for email clients. Place inside Body. Typically max-width 600px.",
+    example: {
+      style: {
+        maxWidth: "600px",
+        margin: "0 auto",
+        padding: "20px 0 48px",
+      },
+    },
+  },
+
+  Section: {
+    props: z.object({
+      style: styleSchema,
+    }),
+    slots: ["default"],
+    description:
+      "Groups related content. Renders as a table-based section for email compatibility.",
+    example: { style: { padding: "24px", backgroundColor: "#ffffff" } },
+  },
+
+  Row: {
+    props: z.object({
+      style: styleSchema,
+    }),
+    slots: ["default"],
+    description:
+      "Horizontal layout row. Use inside Section for multi-column layouts.",
+    example: { style: {} },
+  },
+
+  Column: {
+    props: z.object({
+      style: styleSchema,
+    }),
+    slots: ["default"],
+    description:
+      "Column within a Row. Set width via style for proportional layouts.",
+    example: { style: { width: "50%" } },
+  },
+
+  // ==========================================================================
+  // Content Components
+  // ==========================================================================
+
+  Heading: {
+    props: z.object({
+      text: z.string(),
+      as: z.enum(["h1", "h2", "h3", "h4", "h5", "h6"]).nullable(),
+      style: styleSchema,
+    }),
+    slots: [],
+    description:
+      "Heading text at various levels. h1 is largest, h6 is smallest.",
+    example: { text: "Welcome!", as: "h1" },
+  },
+
+  Text: {
+    props: z.object({
+      text: z.string(),
+      style: styleSchema,
+    }),
+    slots: [],
+    description:
+      "Body text paragraph. Use style for font size, color, weight, and alignment.",
+    example: { text: "Thank you for signing up." },
+  },
+
+  Link: {
+    props: z.object({
+      text: z.string(),
+      href: z.string(),
+      style: styleSchema,
+    }),
+    slots: [],
+    description: "Hyperlink with visible text and a URL.",
+    example: {
+      text: "Visit our website",
+      href: "https://example.com",
+      style: { color: "#2563eb" },
+    },
+  },
+
+  Button: {
+    props: z.object({
+      text: z.string(),
+      href: z.string(),
+      style: styleSchema,
+    }),
+    slots: [],
+    description:
+      "Call-to-action button rendered as a link styled as a button. Provide text and href.",
+    example: {
+      text: "Get Started",
+      href: "https://example.com",
+      style: {
+        backgroundColor: "#5F51E8",
+        borderRadius: "3px",
+        color: "#fff",
+        padding: "12px 20px",
+      },
+    },
+  },
+
+  Image: {
+    props: z.object({
+      src: z.string(),
+      alt: z.string().nullable(),
+      width: z.number().nullable(),
+      height: z.number().nullable(),
+      style: styleSchema,
+    }),
+    slots: [],
+    description:
+      "Image from a URL. src must be a fully qualified URL. Specify width and height for consistent rendering.",
+    example: {
+      src: "https://picsum.photos/400/200?random=1",
+      alt: "Hero image",
+      width: 400,
+      height: 200,
+    },
+  },
+
+  Hr: {
+    props: z.object({
+      style: styleSchema,
+    }),
+    slots: [],
+    description: "Horizontal rule separator between content sections.",
+    example: {
+      style: { borderColor: "#e6ebf1", margin: "20px 0" },
+    },
+  },
+
+  // ==========================================================================
+  // Utility Components
+  // ==========================================================================
+
+  Preview: {
+    props: z.object({
+      text: z.string(),
+    }),
+    slots: [],
+    description:
+      "Preview text shown in email client inboxes before the email is opened. Place inside Html.",
+    example: { text: "You have a new message from Acme Corp" },
+  },
+
+  Markdown: {
+    props: z.object({
+      content: z.string(),
+      markdownContainerStyles: styleSchema,
+      markdownCustomStyles: z.record(z.string(), z.any()).nullable(),
+    }),
+    slots: [],
+    description:
+      "Renders markdown content as email-safe HTML. Supports headings, paragraphs, lists, links, bold, italic, and code.",
+    example: {
+      content: "# Hello\n\nThis is **bold** and *italic* text.",
+    },
+  },
+};
+
+export type StandardComponentDefinitions = typeof standardComponentDefinitions;
+
+export type StandardComponentProps<
+  K extends keyof StandardComponentDefinitions,
+> = StandardComponentDefinitions[K]["props"] extends { _output: infer O }
+  ? O
+  : z.output<StandardComponentDefinitions[K]["props"]>;

+ 1 - 0
packages/react-email/src/components/index.ts

@@ -0,0 +1 @@
+export { standardComponents } from "./standard";

+ 212 - 0
packages/react-email/src/components/standard.tsx

@@ -0,0 +1,212 @@
+import React from "react";
+import {
+  Html as EmailHtml,
+  Head as EmailHead,
+  Body as EmailBody,
+  Container as EmailContainer,
+  Section as EmailSection,
+  Row as EmailRow,
+  Column as EmailColumn,
+  Heading as EmailHeading,
+  Text as EmailText,
+  Link as EmailLink,
+  Button as EmailButton,
+  Img as EmailImg,
+  Hr as EmailHr,
+  Preview as EmailPreview,
+  Markdown as EmailMarkdown,
+} from "@react-email/components";
+import type { ComponentRenderProps } from "../renderer";
+import type { ComponentRegistry } from "../renderer";
+import type { StandardComponentProps } from "../catalog";
+
+// =============================================================================
+// Document Structure
+// =============================================================================
+
+function HtmlComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Html">>) {
+  const p = element.props;
+
+  return (
+    <EmailHtml lang={p.lang ?? undefined} dir={p.dir ?? undefined}>
+      {children}
+    </EmailHtml>
+  );
+}
+
+function HeadComponent({
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Head">>) {
+  return <EmailHead>{children}</EmailHead>;
+}
+
+function BodyComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Body">>) {
+  const p = element.props;
+
+  return <EmailBody style={p.style ?? undefined}>{children}</EmailBody>;
+}
+
+function ContainerComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Container">>) {
+  const p = element.props;
+
+  return (
+    <EmailContainer style={p.style ?? undefined}>{children}</EmailContainer>
+  );
+}
+
+function SectionComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Section">>) {
+  const p = element.props;
+
+  return <EmailSection style={p.style ?? undefined}>{children}</EmailSection>;
+}
+
+function RowComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Row">>) {
+  const p = element.props;
+
+  return <EmailRow style={p.style ?? undefined}>{children}</EmailRow>;
+}
+
+function ColumnComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Column">>) {
+  const p = element.props;
+
+  return <EmailColumn style={p.style ?? undefined}>{children}</EmailColumn>;
+}
+
+// =============================================================================
+// Content Components
+// =============================================================================
+
+function HeadingComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
+  const p = element.props;
+
+  return (
+    <EmailHeading as={p.as ?? "h2"} style={p.style ?? undefined}>
+      {p.text}
+    </EmailHeading>
+  );
+}
+
+function TextComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Text">>) {
+  const p = element.props;
+
+  return <EmailText style={p.style ?? undefined}>{p.text}</EmailText>;
+}
+
+function LinkComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Link">>) {
+  const p = element.props;
+
+  return (
+    <EmailLink href={p.href} style={p.style ?? undefined}>
+      {p.text}
+    </EmailLink>
+  );
+}
+
+function ButtonComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Button">>) {
+  const p = element.props;
+
+  return (
+    <EmailButton href={p.href} style={p.style ?? undefined}>
+      {p.text}
+    </EmailButton>
+  );
+}
+
+function ImageComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Image">>) {
+  const p = element.props;
+
+  return (
+    <EmailImg
+      src={p.src}
+      alt={p.alt ?? undefined}
+      width={p.width ?? undefined}
+      height={p.height ?? undefined}
+      style={p.style ?? undefined}
+    />
+  );
+}
+
+function HrComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Hr">>) {
+  const p = element.props;
+
+  return <EmailHr style={p.style ?? undefined} />;
+}
+
+// =============================================================================
+// Utility Components
+// =============================================================================
+
+function PreviewComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Preview">>) {
+  const p = element.props;
+
+  return <EmailPreview>{p.text}</EmailPreview>;
+}
+
+function MarkdownComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Markdown">>) {
+  const p = element.props;
+
+  return (
+    <EmailMarkdown
+      markdownContainerStyles={p.markdownContainerStyles ?? undefined}
+      markdownCustomStyles={p.markdownCustomStyles ?? undefined}
+    >
+      {p.content}
+    </EmailMarkdown>
+  );
+}
+
+// =============================================================================
+// Registry
+// =============================================================================
+
+export const standardComponents: ComponentRegistry = {
+  Html: HtmlComponent,
+  Head: HeadComponent,
+  Body: BodyComponent,
+  Container: ContainerComponent,
+  Section: SectionComponent,
+  Row: RowComponent,
+  Column: ColumnComponent,
+  Heading: HeadingComponent,
+  Text: TextComponent,
+  Link: LinkComponent,
+  Button: ButtonComponent,
+  Image: ImageComponent,
+  Hr: HrComponent,
+  Preview: PreviewComponent,
+  Markdown: MarkdownComponent,
+};

+ 281 - 0
packages/react-email/src/contexts/actions.tsx

@@ -0,0 +1,281 @@
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from "react";
+import {
+  resolveAction,
+  executeAction,
+  type ActionBinding,
+  type ActionHandler,
+  type ActionConfirm,
+  type ResolvedAction,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+function generateUniqueId(): string {
+  return crypto.randomUUID();
+}
+
+function deepResolveValue(
+  value: unknown,
+  get: (path: string) => unknown,
+): unknown {
+  if (value === null || value === undefined) return value;
+
+  if (value === "$id") {
+    return generateUniqueId();
+  }
+
+  if (typeof value === "object" && !Array.isArray(value)) {
+    const obj = value as Record<string, unknown>;
+    const keys = Object.keys(obj);
+
+    if (keys.length === 1 && typeof obj.$state === "string") {
+      return get(obj.$state as string);
+    }
+
+    if (keys.length === 1 && "$id" in obj) {
+      return generateUniqueId();
+    }
+  }
+
+  if (Array.isArray(value)) {
+    return value.map((item) => deepResolveValue(item, get));
+  }
+
+  if (typeof value === "object") {
+    const resolved: Record<string, unknown> = {};
+    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
+      resolved[key] = deepResolveValue(val, get);
+    }
+    return resolved;
+  }
+
+  return value;
+}
+
+export interface PendingConfirmation {
+  action: ResolvedAction;
+  handler: ActionHandler;
+  resolve: () => void;
+  reject: () => void;
+}
+
+export interface ActionContextValue {
+  handlers: Record<string, ActionHandler>;
+  loadingActions: Set<string>;
+  pendingConfirmation: PendingConfirmation | null;
+  execute: (binding: ActionBinding) => Promise<void>;
+  confirm: () => void;
+  cancel: () => void;
+  registerHandler: (name: string, handler: ActionHandler) => void;
+}
+
+const ActionContext = createContext<ActionContextValue | null>(null);
+
+export interface ActionProviderProps {
+  handlers?: Record<string, ActionHandler>;
+  navigate?: (path: string) => void;
+  children: ReactNode;
+}
+
+export function ActionProvider({
+  handlers: initialHandlers = {},
+  navigate,
+  children,
+}: ActionProviderProps) {
+  const { state, get, set } = useStateStore();
+  const [handlers, setHandlers] =
+    useState<Record<string, ActionHandler>>(initialHandlers);
+  const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
+  const [pendingConfirmation, setPendingConfirmation] =
+    useState<PendingConfirmation | null>(null);
+
+  const registerHandler = useCallback(
+    (name: string, handler: ActionHandler) => {
+      setHandlers((prev) => ({ ...prev, [name]: handler }));
+    },
+    [],
+  );
+
+  const execute = useCallback(
+    async (binding: ActionBinding) => {
+      const resolved = resolveAction(binding, state);
+
+      if (resolved.action === "setState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const value = resolved.params.value;
+        if (statePath) {
+          set(statePath, value);
+        }
+        return;
+      }
+
+      if (resolved.action === "pushState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const rawValue = resolved.params.value;
+        if (statePath) {
+          const resolvedValue = deepResolveValue(rawValue, get);
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(statePath, [...arr, resolvedValue]);
+          const clearStatePath = resolved.params.clearStatePath as
+            | string
+            | undefined;
+          if (clearStatePath) {
+            set(clearStatePath, "");
+          }
+        }
+        return;
+      }
+
+      if (resolved.action === "removeState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const index = resolved.params.index as number;
+        if (statePath !== undefined && index !== undefined) {
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(
+            statePath,
+            arr.filter((_, i) => i !== index),
+          );
+        }
+        return;
+      }
+
+      const handler = handlers[resolved.action];
+
+      if (!handler) {
+        console.warn(`No handler registered for action: ${resolved.action}`);
+        return;
+      }
+
+      if (resolved.confirm) {
+        return new Promise<void>((resolve, reject) => {
+          setPendingConfirmation({
+            action: resolved,
+            handler,
+            resolve: () => {
+              setPendingConfirmation(null);
+              resolve();
+            },
+            reject: () => {
+              setPendingConfirmation(null);
+              reject(new Error("Action cancelled"));
+            },
+          });
+        }).then(async () => {
+          setLoadingActions((prev) => new Set(prev).add(resolved.action));
+          try {
+            await executeAction({
+              action: resolved,
+              handler,
+              setState: set,
+              navigate,
+              executeAction: async (name) => {
+                const subBinding: ActionBinding = { action: name };
+                await execute(subBinding);
+              },
+            });
+          } finally {
+            setLoadingActions((prev) => {
+              const next = new Set(prev);
+              next.delete(resolved.action);
+              return next;
+            });
+          }
+        });
+      }
+
+      setLoadingActions((prev) => new Set(prev).add(resolved.action));
+      try {
+        await executeAction({
+          action: resolved,
+          handler,
+          setState: set,
+          navigate,
+          executeAction: async (name) => {
+            const subBinding: ActionBinding = { action: name };
+            await execute(subBinding);
+          },
+        });
+      } finally {
+        setLoadingActions((prev) => {
+          const next = new Set(prev);
+          next.delete(resolved.action);
+          return next;
+        });
+      }
+    },
+    [state, handlers, get, set, navigate],
+  );
+
+  const confirm = useCallback(() => {
+    pendingConfirmation?.resolve();
+  }, [pendingConfirmation]);
+
+  const cancel = useCallback(() => {
+    pendingConfirmation?.reject();
+  }, [pendingConfirmation]);
+
+  const value = useMemo<ActionContextValue>(
+    () => ({
+      handlers,
+      loadingActions,
+      pendingConfirmation,
+      execute,
+      confirm,
+      cancel,
+      registerHandler,
+    }),
+    [
+      handlers,
+      loadingActions,
+      pendingConfirmation,
+      execute,
+      confirm,
+      cancel,
+      registerHandler,
+    ],
+  );
+
+  return (
+    <ActionContext.Provider value={value}>{children}</ActionContext.Provider>
+  );
+}
+
+export function useActions(): ActionContextValue {
+  const ctx = useContext(ActionContext);
+  if (!ctx) {
+    throw new Error("useActions must be used within an ActionProvider");
+  }
+  return ctx;
+}
+
+export function useAction(binding: ActionBinding): {
+  execute: () => Promise<void>;
+  isLoading: boolean;
+} {
+  const { execute, loadingActions } = useActions();
+  const isLoading = loadingActions.has(binding.action);
+
+  const executeAction = useCallback(() => execute(binding), [execute, binding]);
+
+  return { execute: executeAction, isLoading };
+}
+
+export interface ConfirmDialogProps {
+  confirm: ActionConfirm;
+  onConfirm: () => void;
+  onCancel: () => void;
+}
+
+/**
+ * No-op confirm dialog for email context. Emails are non-interactive,
+ * so confirmations are not rendered.
+ */
+export function ConfirmDialog(_props: ConfirmDialogProps) {
+  return null;
+}

+ 26 - 0
packages/react-email/src/contexts/repeat-scope.tsx

@@ -0,0 +1,26 @@
+import React, { createContext, useContext, type ReactNode } from "react";
+
+export interface RepeatScopeValue {
+  item: unknown;
+  index: number;
+  basePath: string;
+}
+
+const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
+
+export function RepeatScopeProvider({
+  item,
+  index,
+  basePath,
+  children,
+}: RepeatScopeValue & { children: ReactNode }) {
+  return (
+    <RepeatScopeContext.Provider value={{ item, index, basePath }}>
+      {children}
+    </RepeatScopeContext.Provider>
+  );
+}
+
+export function useRepeatScope(): RepeatScopeValue | null {
+  return useContext(RepeatScopeContext);
+}

+ 117 - 0
packages/react-email/src/contexts/state.tsx

@@ -0,0 +1,117 @@
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  useEffect,
+  useRef,
+  type ReactNode,
+} from "react";
+import { getByPath, setByPath, type StateModel } from "@json-render/core";
+
+export interface StateContextValue {
+  state: StateModel;
+  get: (path: string) => unknown;
+  set: (path: string, value: unknown) => void;
+  update: (updates: Record<string, unknown>) => void;
+}
+
+const StateContext = createContext<StateContextValue | null>(null);
+
+export interface StateProviderProps {
+  initialState?: StateModel;
+  onStateChange?: (path: string, value: unknown) => void;
+  children: ReactNode;
+}
+
+export function StateProvider({
+  initialState = {},
+  onStateChange,
+  children,
+}: StateProviderProps) {
+  const [state, setStateInternal] = useState<StateModel>(initialState);
+
+  const stateRef = useRef(state);
+  stateRef.current = state;
+
+  const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
+
+  useEffect(() => {
+    const newJson = JSON.stringify(initialState);
+    if (newJson !== initialStateJsonRef.current) {
+      initialStateJsonRef.current = newJson;
+      if (initialState && Object.keys(initialState).length > 0) {
+        setStateInternal((prev) => ({ ...prev, ...initialState }));
+      }
+    }
+  }, [initialState]);
+
+  const get = useCallback(
+    (path: string) => getByPath(stateRef.current, path),
+    [],
+  );
+
+  const set = useCallback(
+    (path: string, value: unknown) => {
+      setStateInternal((prev) => {
+        const next = { ...prev };
+        setByPath(next, path, value);
+        return next;
+      });
+      onStateChange?.(path, value);
+    },
+    [onStateChange],
+  );
+
+  const update = useCallback(
+    (updates: Record<string, unknown>) => {
+      const entries = Object.entries(updates);
+      setStateInternal((prev) => {
+        const next = { ...prev };
+        for (const [path, value] of entries) {
+          setByPath(next, path, value);
+        }
+        return next;
+      });
+      for (const [path, value] of entries) {
+        onStateChange?.(path, value);
+      }
+    },
+    [onStateChange],
+  );
+
+  const value = useMemo<StateContextValue>(
+    () => ({ state, get, set, update }),
+    [state, get, set, update],
+  );
+
+  return (
+    <StateContext.Provider value={value}>{children}</StateContext.Provider>
+  );
+}
+
+export function useStateStore(): StateContextValue {
+  const ctx = useContext(StateContext);
+  if (!ctx) {
+    throw new Error("useStateStore must be used within a StateProvider");
+  }
+  return ctx;
+}
+
+export function useStateValue<T>(path: string): T | undefined {
+  const { state } = useStateStore();
+  return getByPath(state, path) as T | undefined;
+}
+
+export function useStateBinding<T>(
+  path: string,
+): [T | undefined, (value: T) => void] {
+  const { state, set } = useStateStore();
+  const value = getByPath(state, path) as T | undefined;
+  const setValue = useCallback(
+    (newValue: T) => set(path, newValue),
+    [path, set],
+  );
+  return [value, setValue];
+}

+ 262 - 0
packages/react-email/src/contexts/validation.tsx

@@ -0,0 +1,262 @@
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from "react";
+import {
+  runValidation,
+  type ValidationConfig,
+  type ValidationFunction,
+  type ValidationResult,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+export interface FieldValidationState {
+  touched: boolean;
+  validated: boolean;
+  result: ValidationResult | null;
+}
+
+export interface ValidationContextValue {
+  customFunctions: Record<string, ValidationFunction>;
+  fieldStates: Record<string, FieldValidationState>;
+  validate: (path: string, config: ValidationConfig) => ValidationResult;
+  touch: (path: string) => void;
+  clear: (path: string) => void;
+  validateAll: () => boolean;
+  registerField: (path: string, config: ValidationConfig) => void;
+}
+
+const ValidationContext = createContext<ValidationContextValue | null>(null);
+
+export interface ValidationProviderProps {
+  customFunctions?: Record<string, ValidationFunction>;
+  children: ReactNode;
+}
+
+function dynamicArgsEqual(
+  a: Record<string, unknown> | undefined,
+  b: Record<string, unknown> | undefined,
+): boolean {
+  if (a === b) return true;
+  if (!a || !b) return false;
+
+  const keysA = Object.keys(a);
+  const keysB = Object.keys(b);
+  if (keysA.length !== keysB.length) return false;
+
+  for (const key of keysA) {
+    const va = a[key];
+    const vb = b[key];
+    if (va === vb) continue;
+    if (
+      typeof va === "object" &&
+      va !== null &&
+      typeof vb === "object" &&
+      vb !== null
+    ) {
+      const sa = (va as Record<string, unknown>).$state;
+      const sb = (vb as Record<string, unknown>).$state;
+      if (typeof sa === "string" && sa === sb) continue;
+    }
+    return false;
+  }
+  return true;
+}
+
+function validationConfigEqual(
+  a: ValidationConfig,
+  b: ValidationConfig,
+): boolean {
+  if (a === b) return true;
+  if (a.validateOn !== b.validateOn) return false;
+
+  const ac = a.checks ?? [];
+  const bc = b.checks ?? [];
+  if (ac.length !== bc.length) return false;
+
+  for (let i = 0; i < ac.length; i++) {
+    const ca = ac[i]!;
+    const cb = bc[i]!;
+    if (ca.type !== cb.type) return false;
+    if (ca.message !== cb.message) return false;
+    if (!dynamicArgsEqual(ca.args, cb.args)) return false;
+  }
+
+  return true;
+}
+
+export function ValidationProvider({
+  customFunctions = {},
+  children,
+}: ValidationProviderProps) {
+  const { state } = useStateStore();
+  const [fieldStates, setFieldStates] = useState<
+    Record<string, FieldValidationState>
+  >({});
+  const [fieldConfigs, setFieldConfigs] = useState<
+    Record<string, ValidationConfig>
+  >({});
+
+  const registerField = useCallback(
+    (path: string, config: ValidationConfig) => {
+      setFieldConfigs((prev) => {
+        const existing = prev[path];
+        if (existing && validationConfigEqual(existing, config)) {
+          return prev;
+        }
+        return { ...prev, [path]: config };
+      });
+    },
+    [],
+  );
+
+  const validate = useCallback(
+    (path: string, config: ValidationConfig): ValidationResult => {
+      const segments = path.split("/").filter(Boolean);
+      let value: unknown = state;
+      for (const seg of segments) {
+        if (value != null && typeof value === "object") {
+          value = (value as Record<string, unknown>)[seg];
+        } else {
+          value = undefined;
+          break;
+        }
+      }
+      const result = runValidation(config, {
+        value,
+        stateModel: state,
+        customFunctions,
+      });
+
+      setFieldStates((prev) => ({
+        ...prev,
+        [path]: {
+          touched: prev[path]?.touched ?? true,
+          validated: true,
+          result,
+        },
+      }));
+
+      return result;
+    },
+    [state, customFunctions],
+  );
+
+  const touch = useCallback((path: string) => {
+    setFieldStates((prev) => ({
+      ...prev,
+      [path]: {
+        ...prev[path],
+        touched: true,
+        validated: prev[path]?.validated ?? false,
+        result: prev[path]?.result ?? null,
+      },
+    }));
+  }, []);
+
+  const clear = useCallback((path: string) => {
+    setFieldStates((prev) => {
+      const { [path]: _, ...rest } = prev;
+      return rest;
+    });
+  }, []);
+
+  const validateAll = useCallback(() => {
+    let allValid = true;
+    for (const [path, config] of Object.entries(fieldConfigs)) {
+      const result = validate(path, config);
+      if (!result.valid) {
+        allValid = false;
+      }
+    }
+    return allValid;
+  }, [fieldConfigs, validate]);
+
+  const value = useMemo<ValidationContextValue>(
+    () => ({
+      customFunctions,
+      fieldStates,
+      validate,
+      touch,
+      clear,
+      validateAll,
+      registerField,
+    }),
+    [
+      customFunctions,
+      fieldStates,
+      validate,
+      touch,
+      clear,
+      validateAll,
+      registerField,
+    ],
+  );
+
+  return (
+    <ValidationContext.Provider value={value}>
+      {children}
+    </ValidationContext.Provider>
+  );
+}
+
+export function useValidation(): ValidationContextValue {
+  const ctx = useContext(ValidationContext);
+  if (!ctx) {
+    throw new Error("useValidation must be used within a ValidationProvider");
+  }
+  return ctx;
+}
+
+export function useFieldValidation(
+  path: string,
+  config?: ValidationConfig,
+): {
+  state: FieldValidationState;
+  validate: () => ValidationResult;
+  touch: () => void;
+  clear: () => void;
+  errors: string[];
+  isValid: boolean;
+} {
+  const {
+    fieldStates,
+    validate: validateField,
+    touch: touchField,
+    clear: clearField,
+    registerField,
+  } = useValidation();
+
+  React.useEffect(() => {
+    if (config) {
+      registerField(path, config);
+    }
+  }, [path, config, registerField]);
+
+  const state = fieldStates[path] ?? {
+    touched: false,
+    validated: false,
+    result: null,
+  };
+
+  const validate = useCallback(
+    () => validateField(path, config ?? { checks: [] }),
+    [path, config, validateField],
+  );
+
+  const touch = useCallback(() => touchField(path), [path, touchField]);
+  const clear = useCallback(() => clearField(path), [path, clearField]);
+
+  return {
+    state,
+    validate,
+    touch,
+    clear,
+    errors: state.result?.errors ?? [],
+    isValid: state.result?.valid ?? true,
+  };
+}

+ 64 - 0
packages/react-email/src/contexts/visibility.tsx

@@ -0,0 +1,64 @@
+import React, {
+  createContext,
+  useContext,
+  useMemo,
+  type ReactNode,
+} from "react";
+import {
+  evaluateVisibility,
+  type VisibilityCondition,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+export interface VisibilityContextValue {
+  isVisible: (condition: VisibilityCondition | undefined) => boolean;
+  ctx: CoreVisibilityContext;
+}
+
+const VisibilityContext = createContext<VisibilityContextValue | null>(null);
+
+export interface VisibilityProviderProps {
+  children: ReactNode;
+}
+
+export function VisibilityProvider({ children }: VisibilityProviderProps) {
+  const { state } = useStateStore();
+
+  const ctx: CoreVisibilityContext = useMemo(
+    () => ({ stateModel: state }),
+    [state],
+  );
+
+  const isVisible = useMemo(
+    () => (condition: VisibilityCondition | undefined) =>
+      evaluateVisibility(condition, ctx),
+    [ctx],
+  );
+
+  const value = useMemo<VisibilityContextValue>(
+    () => ({ isVisible, ctx }),
+    [isVisible, ctx],
+  );
+
+  return (
+    <VisibilityContext.Provider value={value}>
+      {children}
+    </VisibilityContext.Provider>
+  );
+}
+
+export function useVisibility(): VisibilityContextValue {
+  const ctx = useContext(VisibilityContext);
+  if (!ctx) {
+    throw new Error("useVisibility must be used within a VisibilityProvider");
+  }
+  return ctx;
+}
+
+export function useIsVisible(
+  condition: VisibilityCondition | undefined,
+): boolean {
+  const { isVisible } = useVisibility();
+  return isVisible(condition);
+}

+ 87 - 0
packages/react-email/src/index.ts

@@ -0,0 +1,87 @@
+// Schema
+export { schema, type ReactEmailSchema, type ReactEmailSpec } from "./schema";
+
+// Core types (re-exported for convenience)
+export type { Spec } from "@json-render/core";
+
+// Catalog-aware types
+export type {
+  SetState,
+  StateModel,
+  ComponentContext,
+  ComponentFn,
+  Components,
+} from "./catalog-types";
+
+// Contexts
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "./contexts/state";
+
+export {
+  VisibilityProvider,
+  useVisibility,
+  useIsVisible,
+  type VisibilityContextValue,
+  type VisibilityProviderProps,
+} from "./contexts/visibility";
+
+export {
+  ActionProvider,
+  useActions,
+  useAction,
+  ConfirmDialog,
+  type ActionContextValue,
+  type ActionProviderProps,
+  type PendingConfirmation,
+  type ConfirmDialogProps,
+} from "./contexts/actions";
+
+export {
+  ValidationProvider,
+  useValidation,
+  useFieldValidation,
+  type ValidationContextValue,
+  type ValidationProviderProps,
+  type FieldValidationState,
+} from "./contexts/validation";
+
+export {
+  RepeatScopeProvider,
+  useRepeatScope,
+  type RepeatScopeValue,
+} from "./contexts/repeat-scope";
+
+// Renderer
+export {
+  defineRegistry,
+  type DefineRegistryResult,
+  createRenderer,
+  type CreateRendererProps,
+  type ComponentMap,
+  Renderer,
+  JSONUIProvider,
+  type ComponentRenderProps,
+  type ComponentRenderer,
+  type ComponentRegistry,
+  type RendererProps,
+  type JSONUIProviderProps,
+} from "./renderer";
+
+// Standard components
+export { standardComponents } from "./components";
+
+// Server-side render functions
+export { renderToHtml, renderToPlainText, type RenderOptions } from "./render";
+
+// Catalog definitions
+export {
+  standardComponentDefinitions,
+  type StandardComponentDefinitions,
+  type StandardComponentProps,
+} from "./catalog";

+ 407 - 0
packages/react-email/src/render.test.tsx

@@ -0,0 +1,407 @@
+import { describe, it, expect } from "vitest";
+import type { Spec } from "@json-render/core";
+import { renderToHtml, renderToPlainText } from "./render";
+import { examples } from "./__fixtures__/examples";
+
+// =============================================================================
+// Helpers
+// =============================================================================
+
+/** Minimal valid email spec — Html > Head + Body */
+function minimalSpec(bodyChildren: Spec["elements"] = {}): Spec {
+  return {
+    root: "html",
+    elements: {
+      html: {
+        type: "Html",
+        props: { lang: "en", dir: null },
+        children: ["head", "body"],
+      },
+      head: { type: "Head", props: {}, children: [] },
+      body: {
+        type: "Body",
+        props: { style: null },
+        children: Object.keys(bodyChildren),
+      },
+      ...bodyChildren,
+    },
+  };
+}
+
+function validateSpecStructure(spec: Spec) {
+  const errors: string[] = [];
+
+  if (!spec.elements[spec.root]) {
+    errors.push(`Root element "${spec.root}" not found in elements`);
+  }
+
+  for (const [id, element] of Object.entries(spec.elements)) {
+    if (element.children) {
+      for (const childId of element.children) {
+        if (!spec.elements[childId]) {
+          errors.push(
+            `Element "${id}" references child "${childId}" which does not exist`,
+          );
+        }
+      }
+    }
+  }
+
+  const referencedIds = new Set<string>();
+  referencedIds.add(spec.root);
+  for (const element of Object.values(spec.elements)) {
+    if (element.children) {
+      for (const childId of element.children) {
+        referencedIds.add(childId);
+      }
+    }
+  }
+  for (const id of Object.keys(spec.elements)) {
+    if (!referencedIds.has(id)) {
+      errors.push(`Element "${id}" is orphaned (not referenced by any parent)`);
+    }
+  }
+
+  return errors;
+}
+
+// =============================================================================
+// renderToHtml
+// =============================================================================
+
+describe("renderToHtml", () => {
+  it("renders a minimal spec to valid HTML", async () => {
+    const html = await renderToHtml(minimalSpec());
+    expect(html).toContain("<!DOCTYPE");
+    expect(html).toContain("<html");
+    expect(html).toContain("</html>");
+  });
+
+  it("renders Text component content", async () => {
+    const spec = minimalSpec({
+      text: {
+        type: "Text",
+        props: { text: "Hello World", style: null },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("Hello World");
+  });
+
+  it("renders Heading with correct tag level", async () => {
+    const spec = minimalSpec({
+      heading: {
+        type: "Heading",
+        props: { text: "Title", as: "h1", style: null },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("<h1");
+    expect(html).toContain("Title");
+  });
+
+  it("renders Link with href", async () => {
+    const spec = minimalSpec({
+      link: {
+        type: "Link",
+        props: {
+          text: "Click here",
+          href: "https://example.com",
+          style: null,
+        },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("https://example.com");
+    expect(html).toContain("Click here");
+  });
+
+  it("renders Button with href and text", async () => {
+    const spec = minimalSpec({
+      btn: {
+        type: "Button",
+        props: {
+          text: "Get Started",
+          href: "https://example.com/start",
+          style: null,
+        },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("https://example.com/start");
+    expect(html).toContain("Get Started");
+  });
+
+  it("renders Image with src and alt", async () => {
+    const spec = minimalSpec({
+      img: {
+        type: "Image",
+        props: {
+          src: "https://example.com/logo.png",
+          alt: "Logo",
+          width: 100,
+          height: 50,
+          style: null,
+        },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("https://example.com/logo.png");
+    expect(html).toContain('alt="Logo"');
+  });
+
+  it("renders Hr element", async () => {
+    const spec = minimalSpec({
+      divider: {
+        type: "Hr",
+        props: { style: { borderColor: "#eaeaea" } },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("<hr");
+  });
+
+  it("renders Preview text", async () => {
+    const spec: Spec = {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "preview", "body"],
+        },
+        head: { type: "Head", props: {}, children: [] },
+        preview: {
+          type: "Preview",
+          props: { text: "Inbox preview text" },
+          children: [],
+        },
+        body: { type: "Body", props: { style: null }, children: [] },
+      },
+    };
+    const html = await renderToHtml(spec);
+    expect(html).toContain("Inbox preview text");
+  });
+
+  it("renders nested Container > Section > Text", async () => {
+    const spec = minimalSpec({
+      container: {
+        type: "Container",
+        props: { style: { maxWidth: "600px" } },
+        children: ["section"],
+      },
+      section: {
+        type: "Section",
+        props: { style: null },
+        children: ["text"],
+      },
+      text: {
+        type: "Text",
+        props: { text: "Nested content", style: null },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("Nested content");
+  });
+
+  it("renders Row and Column layout", async () => {
+    const spec = minimalSpec({
+      section: {
+        type: "Section",
+        props: { style: null },
+        children: ["row"],
+      },
+      row: {
+        type: "Row",
+        props: { style: null },
+        children: ["col1", "col2"],
+      },
+      col1: {
+        type: "Column",
+        props: { style: { width: "50%" } },
+        children: ["left-text"],
+      },
+      col2: {
+        type: "Column",
+        props: { style: { width: "50%" } },
+        children: ["right-text"],
+      },
+      "left-text": {
+        type: "Text",
+        props: { text: "Left side", style: null },
+        children: [],
+      },
+      "right-text": {
+        type: "Text",
+        props: { text: "Right side", style: null },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("Left side");
+    expect(html).toContain("Right side");
+  });
+
+  it("renders Markdown content", async () => {
+    const spec = minimalSpec({
+      md: {
+        type: "Markdown",
+        props: {
+          content: "# Hello\n\nThis is **bold** text.",
+          markdownContainerStyles: null,
+          markdownCustomStyles: null,
+        },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("Hello");
+    expect(html).toContain("bold");
+  });
+
+  it("applies inline styles", async () => {
+    const spec = minimalSpec({
+      text: {
+        type: "Text",
+        props: {
+          text: "Styled",
+          style: { color: "#ff0000", fontSize: "20px" },
+        },
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("color:#ff0000");
+    expect(html).toContain("font-size:20px");
+  });
+
+  it("skips unknown component types gracefully", async () => {
+    const spec = minimalSpec({
+      unknown: {
+        type: "NonExistentComponent",
+        props: {},
+        children: [],
+      },
+    });
+    const html = await renderToHtml(spec);
+    expect(html).toContain("<!DOCTYPE");
+  });
+
+  it("handles missing child elements gracefully", async () => {
+    const spec: Spec = {
+      root: "html",
+      elements: {
+        html: {
+          type: "Html",
+          props: { lang: "en", dir: null },
+          children: ["head", "body"],
+        },
+        head: { type: "Head", props: {}, children: [] },
+        body: {
+          type: "Body",
+          props: { style: null },
+          children: ["missing-child"],
+        },
+      },
+    };
+    const html = await renderToHtml(spec);
+    expect(html).toContain("<!DOCTYPE");
+  });
+});
+
+// =============================================================================
+// renderToPlainText
+// =============================================================================
+
+describe("renderToPlainText", () => {
+  it("extracts text content from spec", async () => {
+    const spec = minimalSpec({
+      text: {
+        type: "Text",
+        props: { text: "Plain text content", style: null },
+        children: [],
+      },
+    });
+    const text = await renderToPlainText(spec);
+    expect(text).toContain("Plain text content");
+  });
+
+  it("extracts link text and URL", async () => {
+    const spec = minimalSpec({
+      link: {
+        type: "Link",
+        props: {
+          text: "Visit site",
+          href: "https://example.com",
+          style: null,
+        },
+        children: [],
+      },
+    });
+    const text = await renderToPlainText(spec);
+    expect(text).toContain("Visit site");
+    expect(text).toContain("https://example.com");
+  });
+
+  it("returns string for minimal spec", async () => {
+    const text = await renderToPlainText(minimalSpec());
+    expect(typeof text).toBe("string");
+  });
+});
+
+// =============================================================================
+// Example specs — structural validation + render smoke tests
+// =============================================================================
+
+describe("example specs", () => {
+  it("has at least one example", () => {
+    expect(examples.length).toBeGreaterThan(0);
+  });
+
+  for (const example of examples) {
+    describe(example.name, () => {
+      it("has required metadata", () => {
+        expect(example.name).toBeTruthy();
+        expect(example.label).toBeTruthy();
+        expect(example.description).toBeTruthy();
+      });
+
+      it("has a valid spec structure (no dangling children, no orphans)", () => {
+        const errors = validateSpecStructure(example.spec);
+        expect(errors).toEqual([]);
+      });
+
+      it("root element is Html with Head and Body children", () => {
+        const root = example.spec.elements[example.spec.root];
+        expect(root).toBeDefined();
+        expect(root!.type).toBe("Html");
+
+        const childTypes = root!.children?.map(
+          (id) => example.spec.elements[id]?.type,
+        );
+        expect(childTypes).toContain("Head");
+        expect(childTypes).toContain("Body");
+      });
+
+      it("renders to HTML without errors", async () => {
+        const html = await renderToHtml(example.spec);
+        expect(html).toBeTruthy();
+        expect(html).toContain("<!DOCTYPE");
+        expect(html).toContain("</html>");
+      });
+
+      it("renders to plain text without errors", async () => {
+        const text = await renderToPlainText(example.spec);
+        expect(text).toBeTruthy();
+        expect(typeof text).toBe("string");
+      });
+    });
+  }
+});

+ 168 - 0
packages/react-email/src/render.tsx

@@ -0,0 +1,168 @@
+import React from "react";
+import { render } from "@react-email/render";
+import type { Spec, UIElement } from "@json-render/core";
+import {
+  resolveElementProps,
+  evaluateVisibility,
+  getByPath,
+  type PropResolutionContext,
+} from "@json-render/core";
+import { standardComponents } from "./components/standard";
+
+// Re-export the standard components for use in custom registries
+export { standardComponents };
+
+export type RenderComponentRegistry = Record<string, React.ComponentType<any>>;
+
+export interface RenderOptions {
+  registry?: RenderComponentRegistry;
+  includeStandard?: boolean;
+  state?: Record<string, unknown>;
+}
+
+const noopEmit = () => {};
+
+function renderElement(
+  elementKey: string,
+  spec: Spec,
+  registry: RenderComponentRegistry,
+  stateModel: Record<string, unknown>,
+  repeatItem?: unknown,
+  repeatIndex?: number,
+  repeatBasePath?: string,
+): React.ReactElement | null {
+  const element = spec.elements[elementKey];
+  if (!element) return null;
+
+  const ctx: PropResolutionContext = {
+    stateModel,
+    repeatItem,
+    repeatIndex,
+    repeatBasePath,
+  };
+
+  if (element.visible !== undefined) {
+    if (!evaluateVisibility(element.visible, ctx)) {
+      return null;
+    }
+  }
+
+  const resolvedProps = resolveElementProps(
+    element.props as Record<string, unknown>,
+    ctx,
+  );
+  const resolvedElement: UIElement = { ...element, props: resolvedProps };
+
+  const Component = registry[resolvedElement.type];
+  if (!Component) return null;
+
+  if (resolvedElement.repeat) {
+    const items =
+      (getByPath(stateModel, resolvedElement.repeat.statePath) as
+        | unknown[]
+        | undefined) ?? [];
+
+    const repeat = resolvedElement.repeat!;
+    const fragments = items.map((item, index) => {
+      const repeatKey = repeat.key;
+      const key =
+        repeatKey && typeof item === "object" && item !== null
+          ? String((item as Record<string, unknown>)[repeatKey] ?? index)
+          : String(index);
+
+      const childPath = `${repeat.statePath}/${index}`;
+      const children = resolvedElement.children?.map((childKey) =>
+        renderElement(
+          childKey,
+          spec,
+          registry,
+          stateModel,
+          item,
+          index,
+          childPath,
+        ),
+      );
+
+      return (
+        <Component key={key} element={resolvedElement} emit={noopEmit}>
+          {children}
+        </Component>
+      );
+    });
+
+    return <>{fragments}</>;
+  }
+
+  const children = resolvedElement.children?.map((childKey) =>
+    renderElement(
+      childKey,
+      spec,
+      registry,
+      stateModel,
+      repeatItem,
+      repeatIndex,
+      repeatBasePath,
+    ),
+  );
+
+  return (
+    <Component key={elementKey} element={resolvedElement} emit={noopEmit}>
+      {children && children.length > 0 ? children : undefined}
+    </Component>
+  );
+}
+
+function buildDocument(
+  spec: Spec,
+  options: RenderOptions = {},
+): React.ReactElement {
+  const {
+    registry: customRegistry,
+    includeStandard = true,
+    state = {},
+  } = options;
+
+  const mergedState: Record<string, unknown> = {
+    ...spec.state,
+    ...state,
+  };
+
+  const registry: RenderComponentRegistry = {
+    ...(includeStandard ? standardComponents : {}),
+    ...customRegistry,
+  };
+
+  const root = renderElement(spec.root, spec, registry, mergedState);
+  if (!root) {
+    console.warn(
+      `[json-render/react-email] Root element "${spec.root}" not found in spec.elements`,
+    );
+  }
+  return root ?? <></>;
+}
+
+/**
+ * Render a json-render spec to an HTML email string.
+ *
+ * This is a standalone server-side function that resolves the spec tree
+ * without React hooks or contexts, making it safe to import in Next.js
+ * route handlers and other server-only environments.
+ */
+export async function renderToHtml(
+  spec: Spec,
+  options?: RenderOptions,
+): Promise<string> {
+  const document = buildDocument(spec, options);
+  return render(document);
+}
+
+/**
+ * Render a json-render spec to a plain text email string.
+ */
+export async function renderToPlainText(
+  spec: Spec,
+  options?: RenderOptions,
+): Promise<string> {
+  const document = buildDocument(spec, options);
+  return render(document, { plainText: true });
+}

+ 502 - 0
packages/react-email/src/renderer.tsx

@@ -0,0 +1,502 @@
+import React, {
+  type ComponentType,
+  type ErrorInfo,
+  type ReactNode,
+  useCallback,
+  useMemo,
+} from "react";
+import type {
+  UIElement,
+  Spec,
+  ActionBinding,
+  Catalog,
+  SchemaDefinition,
+} from "@json-render/core";
+import {
+  resolveElementProps,
+  resolveBindings,
+  resolveActionParam,
+  evaluateVisibility,
+  getByPath,
+  type PropResolutionContext,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import type { Components, SetState, StateModel } from "./catalog-types";
+import { useIsVisible, useVisibility } from "./contexts/visibility";
+import { useActions } from "./contexts/actions";
+import { useStateStore } from "./contexts/state";
+import { StateProvider } from "./contexts/state";
+import { VisibilityProvider } from "./contexts/visibility";
+import { ActionProvider } from "./contexts/actions";
+import { ValidationProvider } from "./contexts/validation";
+import { standardComponents } from "./components/standard";
+import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export interface ComponentRenderProps<P = Record<string, unknown>> {
+  element: UIElement<string, P>;
+  children?: ReactNode;
+  emit: (event: string) => void;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+
+export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
+  ComponentRenderProps<P>
+>;
+
+export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
+
+export interface RendererProps {
+  spec: Spec | null;
+  registry?: ComponentRegistry;
+  includeStandard?: boolean;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+// =============================================================================
+// ElementErrorBoundary
+// =============================================================================
+
+interface ElementErrorBoundaryProps {
+  elementType: string;
+  children: ReactNode;
+}
+
+interface ElementErrorBoundaryState {
+  hasError: boolean;
+}
+
+class ElementErrorBoundary extends React.Component<
+  ElementErrorBoundaryProps,
+  ElementErrorBoundaryState
+> {
+  constructor(props: ElementErrorBoundaryProps) {
+    super(props);
+    this.state = { hasError: false };
+  }
+
+  static getDerivedStateFromError(): ElementErrorBoundaryState {
+    return { hasError: true };
+  }
+
+  componentDidCatch(error: Error, info: ErrorInfo) {
+    console.error(
+      `[json-render/react-email] Rendering error in <${this.props.elementType}>:`,
+      error,
+      info.componentStack,
+    );
+  }
+
+  render() {
+    if (this.state.hasError) {
+      return null;
+    }
+    return this.props.children;
+  }
+}
+
+// =============================================================================
+// ElementRenderer
+// =============================================================================
+
+interface ElementRendererProps {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+const ElementRenderer = React.memo(function ElementRenderer({
+  element,
+  spec,
+  registry,
+  loading,
+  fallback,
+}: ElementRendererProps) {
+  const repeatScope = useRepeatScope();
+  const { ctx } = useVisibility();
+  const { execute } = useActions();
+
+  const fullCtx: PropResolutionContext = useMemo(
+    () =>
+      repeatScope
+        ? {
+            ...ctx,
+            repeatItem: repeatScope.item,
+            repeatIndex: repeatScope.index,
+            repeatBasePath: repeatScope.basePath,
+          }
+        : ctx,
+    [ctx, repeatScope],
+  );
+
+  const isVisible =
+    element.visible === undefined
+      ? true
+      : evaluateVisibility(element.visible, fullCtx);
+
+  const onBindings = element.on;
+  const emit = useCallback(
+    (eventName: string) => {
+      const binding = onBindings?.[eventName];
+      if (!binding) return;
+      const actionBindings = Array.isArray(binding) ? binding : [binding];
+      for (const b of actionBindings) {
+        if (!b.params) {
+          execute(b);
+          continue;
+        }
+        const resolved: Record<string, unknown> = {};
+        for (const [key, val] of Object.entries(b.params)) {
+          resolved[key] = resolveActionParam(val, fullCtx);
+        }
+        execute({ ...b, params: resolved });
+      }
+    },
+    [onBindings, execute, fullCtx],
+  );
+
+  if (!isVisible) {
+    return null;
+  }
+
+  const rawProps = element.props as Record<string, unknown>;
+  const elementBindings = resolveBindings(rawProps, fullCtx);
+  const resolvedProps = resolveElementProps(rawProps, fullCtx);
+
+  const resolvedElement =
+    resolvedProps !== element.props
+      ? { ...element, props: resolvedProps }
+      : element;
+
+  const Component = registry[resolvedElement.type] ?? fallback;
+
+  if (!Component) {
+    console.warn(
+      `[json-render/react-email] No renderer for component type: ${resolvedElement.type}`,
+    );
+    return null;
+  }
+
+  const children = resolvedElement.repeat ? (
+    <RepeatChildren
+      element={resolvedElement}
+      spec={spec}
+      registry={registry}
+      loading={loading}
+      fallback={fallback}
+    />
+  ) : (
+    resolvedElement.children?.map((childKey) => {
+      const childElement = spec.elements[childKey];
+      if (!childElement) {
+        if (!loading) {
+          console.warn(
+            `[json-render/react-email] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`,
+          );
+        }
+        return null;
+      }
+      return (
+        <ElementRenderer
+          key={childKey}
+          element={childElement}
+          spec={spec}
+          registry={registry}
+          loading={loading}
+          fallback={fallback}
+        />
+      );
+    })
+  );
+
+  return (
+    <ElementErrorBoundary elementType={resolvedElement.type}>
+      <Component
+        element={resolvedElement}
+        emit={emit}
+        bindings={elementBindings}
+        loading={loading}
+      >
+        {children}
+      </Component>
+    </ElementErrorBoundary>
+  );
+});
+
+// =============================================================================
+// RepeatChildren
+// =============================================================================
+
+function RepeatChildren({
+  element,
+  spec,
+  registry,
+  loading,
+  fallback,
+}: {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}) {
+  const { state } = useStateStore();
+  const repeat = element.repeat!;
+  const statePath = repeat.statePath;
+
+  const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
+
+  return (
+    <>
+      {items.map((itemValue, index) => {
+        const key =
+          repeat.key && typeof itemValue === "object" && itemValue !== null
+            ? String(
+                (itemValue as Record<string, unknown>)[repeat.key] ?? index,
+              )
+            : String(index);
+
+        return (
+          <RepeatScopeProvider
+            key={key}
+            item={itemValue}
+            index={index}
+            basePath={`${statePath}/${index}`}
+          >
+            {element.children?.map((childKey) => {
+              const childElement = spec.elements[childKey];
+              if (!childElement) {
+                if (!loading) {
+                  console.warn(
+                    `[json-render/react-email] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`,
+                  );
+                }
+                return null;
+              }
+              return (
+                <ElementRenderer
+                  key={childKey}
+                  element={childElement}
+                  spec={spec}
+                  registry={registry}
+                  loading={loading}
+                  fallback={fallback}
+                />
+              );
+            })}
+          </RepeatScopeProvider>
+        );
+      })}
+    </>
+  );
+}
+
+// =============================================================================
+// Renderer
+// =============================================================================
+
+export function Renderer({
+  spec,
+  registry: customRegistry,
+  includeStandard = true,
+  loading,
+  fallback,
+}: RendererProps) {
+  const registry: ComponentRegistry = useMemo(
+    () => ({
+      ...(includeStandard ? standardComponents : {}),
+      ...customRegistry,
+    }),
+    [customRegistry, includeStandard],
+  );
+
+  if (!spec || !spec.root) {
+    return null;
+  }
+
+  const rootElement = spec.elements[spec.root];
+  if (!rootElement) {
+    return null;
+  }
+
+  return (
+    <ElementRenderer
+      element={rootElement}
+      spec={spec}
+      registry={registry}
+      loading={loading}
+      fallback={fallback}
+    />
+  );
+}
+
+// =============================================================================
+// JSONUIProvider
+// =============================================================================
+
+export interface JSONUIProviderProps {
+  initialState?: Record<string, unknown>;
+  handlers?: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<unknown> | unknown
+  >;
+  navigate?: (path: string) => void;
+  validationFunctions?: Record<
+    string,
+    (value: unknown, args?: Record<string, unknown>) => boolean
+  >;
+  onStateChange?: (path: string, value: unknown) => void;
+  children: ReactNode;
+}
+
+export function JSONUIProvider({
+  initialState,
+  handlers,
+  navigate,
+  validationFunctions,
+  onStateChange,
+  children,
+}: JSONUIProviderProps) {
+  return (
+    <StateProvider initialState={initialState} onStateChange={onStateChange}>
+      <VisibilityProvider>
+        <ActionProvider handlers={handlers} navigate={navigate}>
+          <ValidationProvider customFunctions={validationFunctions}>
+            {children}
+          </ValidationProvider>
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+
+// =============================================================================
+// defineRegistry
+// =============================================================================
+
+export interface DefineRegistryResult {
+  registry: ComponentRegistry;
+}
+
+type DefineRegistryComponentFn = (ctx: {
+  props: unknown;
+  children?: React.ReactNode;
+  emit: (event: string) => void;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}) => React.ReactNode;
+
+export function defineRegistry<C extends Catalog>(
+  _catalog: C,
+  options: {
+    components?: Components<C>;
+  },
+): DefineRegistryResult {
+  const registry: ComponentRegistry = {};
+  if (options.components) {
+    for (const [name, componentFn] of Object.entries(options.components)) {
+      registry[name] = ({
+        element,
+        children,
+        emit,
+        bindings,
+        loading,
+      }: ComponentRenderProps) => {
+        return (componentFn as DefineRegistryComponentFn)({
+          props: element.props,
+          children,
+          emit,
+          bindings,
+          loading,
+        });
+      };
+    }
+  }
+
+  return { registry };
+}
+
+// =============================================================================
+// createRenderer
+// =============================================================================
+
+export interface CreateRendererProps {
+  spec: Spec | null;
+  state?: Record<string, unknown>;
+  onAction?: (actionName: string, params?: Record<string, unknown>) => void;
+  onStateChange?: (path: string, value: unknown) => void;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+export type ComponentMap<
+  TComponents extends Record<string, { props: unknown }>,
+> = {
+  [K in keyof TComponents]: ComponentType<
+    ComponentRenderProps<
+      TComponents[K]["props"] extends { _output: infer O }
+        ? O
+        : Record<string, unknown>
+    >
+  >;
+};
+
+export function createRenderer<
+  TDef extends SchemaDefinition,
+  TCatalog extends { components: Record<string, { props: unknown }> },
+>(
+  catalog: Catalog<TDef, TCatalog>,
+  components: ComponentMap<TCatalog["components"]>,
+): ComponentType<CreateRendererProps> {
+  const registry: ComponentRegistry =
+    components as unknown as ComponentRegistry;
+
+  return function CatalogRenderer({
+    spec,
+    state,
+    onAction,
+    onStateChange,
+    loading,
+    fallback,
+  }: CreateRendererProps) {
+    const actionHandlers = onAction
+      ? new Proxy(
+          {} as Record<
+            string,
+            (params: Record<string, unknown>) => void | Promise<void>
+          >,
+          {
+            get: (_target, prop: string) => {
+              return (params: Record<string, unknown>) =>
+                onAction(prop, params);
+            },
+            has: () => true,
+          },
+        )
+      : undefined;
+
+    return (
+      <StateProvider initialState={state} onStateChange={onStateChange}>
+        <VisibilityProvider>
+          <ActionProvider handlers={actionHandlers}>
+            <ValidationProvider>
+              <Renderer
+                spec={spec}
+                registry={registry}
+                loading={loading}
+                fallback={fallback}
+              />
+            </ValidationProvider>
+          </ActionProvider>
+        </VisibilityProvider>
+      </StateProvider>
+    );
+  };
+}

+ 57 - 0
packages/react-email/src/schema.ts

@@ -0,0 +1,57 @@
+import { defineSchema } from "@json-render/core";
+
+/**
+ * The schema for @json-render/react-email
+ *
+ * Defines:
+ * - Spec: A flat tree of elements with keys, types, props, and children references
+ * - Catalog: Components with props schemas
+ *
+ * Reuses the same { root, elements } spec format as the React and React Native renderers.
+ */
+export const schema = defineSchema(
+  (s) => ({
+    spec: s.object({
+      root: s.string(),
+      elements: s.record(
+        s.object({
+          type: s.ref("catalog.components"),
+          props: s.propsOf("catalog.components"),
+          children: s.array(s.string()),
+          visible: s.any(),
+        }),
+      ),
+    }),
+
+    catalog: s.object({
+      components: s.map({
+        props: s.zod(),
+        slots: s.array(s.string()),
+        description: s.string(),
+        example: s.any(),
+      }),
+    }),
+  }),
+  {
+    defaultRules: [
+      "The root element MUST be an Html component. Its children MUST include Head and Body components.",
+      "Body should contain a Container component to constrain width (typically 600px max for email clients).",
+      "All styles MUST be inline. Email clients strip <style> tags, so every component that accepts a style prop should use it for visual customization.",
+      "Image src must be a fully qualified URL (absolute, not relative). For placeholder images use https://picsum.photos/{width}/{height}?random={n}.",
+      "Emails are static documents. There are no interactive actions or form inputs.",
+      "Use Section, Row, and Column for layout. These map to table-based email structures for maximum compatibility.",
+      "Use Preview to set the preview text shown in email client inboxes before opening.",
+      "Use Heading (h1-h6) and Text for all text content. Raw strings are not supported.",
+      "Button renders as a link styled as a button. Always provide both text and href.",
+      "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
+    ],
+  },
+);
+
+export type ReactEmailSchema = typeof schema;
+
+export type ReactEmailSpec<TCatalog> = typeof schema extends {
+  createCatalog: (catalog: TCatalog) => { _specType: infer S };
+}
+  ? S
+  : never;

+ 20 - 0
packages/react-email/src/server.ts

@@ -0,0 +1,20 @@
+// Server-safe entry point: schema and catalog definitions only.
+// Does not import React or @react-email/components.
+
+export { schema, type ReactEmailSchema, type ReactEmailSpec } from "./schema";
+
+export {
+  standardComponentDefinitions,
+  type StandardComponentDefinitions,
+  type StandardComponentProps,
+} from "./catalog";
+
+export type { Spec } from "@json-render/core";
+
+export type {
+  SetState,
+  StateModel,
+  ComponentContext,
+  ComponentFn,
+  Components,
+} from "./catalog-types";

+ 8 - 0
packages/react-email/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "@internal/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 16 - 0
packages/react-email/tsup.config.ts

@@ -0,0 +1,16 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: [
+    "react",
+    "@json-render/core",
+    "@react-email/components",
+    "@react-email/render",
+    "zod",
+  ],
+});

+ 528 - 2
pnpm-lock.yaml

@@ -584,6 +584,79 @@ importers:
         specifier: ^5.7.2
         version: 5.9.2
 
+  examples/react-email:
+    dependencies:
+      '@ai-sdk/gateway':
+        specifier: ^3.0.52
+        version: 3.0.57(zod@4.3.5)
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@json-render/react-email':
+        specifier: workspace:*
+        version: link:../../packages/react-email
+      '@tailwindcss/postcss':
+        specifier: ^4.1.18
+        version: 4.1.18
+      ai:
+        specifier: 6.0.94
+        version: 6.0.94(zod@4.3.5)
+      class-variance-authority:
+        specifier: ^0.7.1
+        version: 0.7.1
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
+      lucide-react:
+        specifier: ^0.575.0
+        version: 0.575.0(react@19.2.4)
+      next:
+        specifier: 16.1.6
+        version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      radix-ui:
+        specifier: ^1.4.3
+        version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      react:
+        specifier: 19.2.4
+        version: 19.2.4
+      react-dom:
+        specifier: 19.2.4
+        version: 19.2.4(react@19.2.4)
+      react-resizable-panels:
+        specifier: ^4.4.1
+        version: 4.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      tailwind-merge:
+        specifier: ^3.5.0
+        version: 3.5.0
+      tailwindcss:
+        specifier: ^4.1.18
+        version: 4.1.18
+      zod:
+        specifier: 4.3.5
+        version: 4.3.5
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../../packages/typescript-config
+      '@types/node':
+        specifier: ^22.10.0
+        version: 22.19.6
+      '@types/react':
+        specifier: 19.2.3
+        version: 19.2.3
+      '@types/react-dom':
+        specifier: 19.2.3
+        version: 19.2.3(@types/react@19.2.3)
+      shadcn:
+        specifier: ^3.8.5
+        version: 3.8.5(@types/node@22.19.6)(typescript@5.9.2)
+      tw-animate-css:
+        specifier: ^1.4.0
+        version: 1.4.0
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.2
+
   examples/react-native:
     dependencies:
       '@ai-sdk/gateway':
@@ -1095,6 +1168,37 @@ importers:
         specifier: ^5.4.5
         version: 5.9.2
 
+  packages/react-email:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+      '@react-email/components':
+        specifier: ^1.0.8
+        version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      '@react-email/render':
+        specifier: ^2.0.4
+        version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      react:
+        specifier: ^19.0.0
+        version: 19.2.4
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      '@types/react':
+        specifier: 19.2.3
+        version: 19.2.3
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.2
+      zod:
+        specifier: ^4.0.0
+        version: 4.3.6
+
   packages/react-native:
     dependencies:
       '@json-render/core':
@@ -4310,6 +4414,165 @@ packages:
   '@radix-ui/rect@1.1.1':
     resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
 
+  '@react-email/body@0.2.1':
+    resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/button@0.2.1':
+    resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/code-block@0.2.1':
+    resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/code-inline@0.0.6':
+    resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/column@0.0.14':
+    resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/components@1.0.8':
+    resolution: {integrity: sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/container@0.0.16':
+    resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/font@0.0.10':
+    resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/head@0.0.13':
+    resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/heading@0.0.16':
+    resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/hr@0.0.12':
+    resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/html@0.0.12':
+    resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/img@0.0.12':
+    resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/link@0.0.13':
+    resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/markdown@0.0.18':
+    resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/preview@0.0.14':
+    resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/render@2.0.4':
+    resolution: {integrity: sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/row@0.0.13':
+    resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/section@0.0.17':
+    resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+  '@react-email/tailwind@2.0.5':
+    resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      '@react-email/body': 0.2.1
+      '@react-email/button': 0.2.1
+      '@react-email/code-block': 0.2.1
+      '@react-email/code-inline': 0.0.6
+      '@react-email/container': 0.0.16
+      '@react-email/heading': 0.0.16
+      '@react-email/hr': 0.0.12
+      '@react-email/img': 0.0.12
+      '@react-email/link': 0.0.13
+      '@react-email/preview': 0.0.14
+      '@react-email/text': 0.1.6
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@react-email/body':
+        optional: true
+      '@react-email/button':
+        optional: true
+      '@react-email/code-block':
+        optional: true
+      '@react-email/code-inline':
+        optional: true
+      '@react-email/container':
+        optional: true
+      '@react-email/heading':
+        optional: true
+      '@react-email/hr':
+        optional: true
+      '@react-email/img':
+        optional: true
+      '@react-email/link':
+        optional: true
+      '@react-email/preview':
+        optional: true
+
+  '@react-email/text@0.1.6':
+    resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: ^18.0 || ^19.0 || ^19.0.0-rc
+
   '@react-native/assets-registry@0.81.4':
     resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==}
     engines: {node: '>= 20.19.4'}
@@ -4928,6 +5191,9 @@ packages:
   '@sec-ant/readable-stream@0.4.1':
     resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
 
+  '@selderee/plugin-htmlparser2@0.11.0':
+    resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
+
   '@shikijs/core@3.21.0':
     resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
 
@@ -6770,11 +7036,24 @@ packages:
   dom-helpers@5.2.1:
     resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
 
+  dom-serializer@2.0.0:
+    resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+  domelementtype@2.3.0:
+    resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
   domexception@2.0.1:
     resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==}
     engines: {node: '>=8'}
     deprecated: Use your platform's native DOMException instead
 
+  domhandler@5.0.3:
+    resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+    engines: {node: '>= 4'}
+
+  domutils@3.2.2:
+    resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
   dotenv-expand@11.0.7:
     resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
     engines: {node: '>=12'}
@@ -6968,6 +7247,10 @@ packages:
     resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
     engines: {node: '>=8.6'}
 
+  entities@4.5.0:
+    resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+    engines: {node: '>=0.12'}
+
   entities@6.0.1:
     resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
     engines: {node: '>=0.12'}
@@ -7867,12 +8150,19 @@ packages:
   html-escaper@2.0.2:
     resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
 
+  html-to-text@9.0.5:
+    resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
+    engines: {node: '>=14'}
+
   html-url-attributes@3.0.1:
     resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
 
   html-void-elements@3.0.0:
     resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
 
+  htmlparser2@8.0.2:
+    resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+
   http-errors@2.0.1:
     resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
     engines: {node: '>= 0.8'}
@@ -8612,6 +8902,9 @@ packages:
     resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==}
     hasBin: true
 
+  leac@0.6.0:
+    resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
+
   leven@3.1.0:
     resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
     engines: {node: '>=6'}
@@ -8843,6 +9136,11 @@ packages:
   markdown-table@3.0.4:
     resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
 
+  marked@15.0.12:
+    resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
+    engines: {node: '>= 18'}
+    hasBin: true
+
   marked@17.0.1:
     resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
     engines: {node: '>= 20'}
@@ -9553,6 +9851,9 @@ packages:
   parse5@8.0.0:
     resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
 
+  parseley@0.12.1:
+    resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
+
   parseurl@1.3.3:
     resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
     engines: {node: '>= 0.8'}
@@ -9600,6 +9901,9 @@ packages:
   pathe@2.0.3:
     resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
 
+  peberminta@0.9.0:
+    resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
+
   pend@1.2.0:
     resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
 
@@ -9769,6 +10073,10 @@ packages:
     resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
     engines: {node: '>=18'}
 
+  prismjs@1.30.0:
+    resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+    engines: {node: '>=6'}
+
   proc-log@4.2.0:
     resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -10345,6 +10653,9 @@ packages:
     resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
     engines: {node: '>=4'}
 
+  selderee@0.11.0:
+    resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
+
   semver@6.3.1:
     resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
     hasBin: true
@@ -11750,6 +12061,9 @@ packages:
   zod@3.25.76:
     resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
 
+  zod@4.3.5:
+    resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
+
   zod@4.3.6:
     resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
 
@@ -11832,6 +12146,13 @@ snapshots:
       '@vercel/oidc': 3.1.0
       zod: 4.3.6
 
+  '@ai-sdk/gateway@3.0.52(zod@4.3.5)':
+    dependencies:
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
+      '@vercel/oidc': 3.1.0
+      zod: 4.3.5
+
   '@ai-sdk/gateway@3.0.52(zod@4.3.6)':
     dependencies:
       '@ai-sdk/provider': 3.0.8
@@ -11846,6 +12167,13 @@ snapshots:
       '@vercel/oidc': 3.1.0
       zod: 4.3.6
 
+  '@ai-sdk/gateway@3.0.57(zod@4.3.5)':
+    dependencies:
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
+      '@vercel/oidc': 3.1.0
+      zod: 4.3.5
+
   '@ai-sdk/gateway@3.0.57(zod@4.3.6)':
     dependencies:
       '@ai-sdk/provider': 3.0.8
@@ -11867,6 +12195,13 @@ snapshots:
       eventsource-parser: 3.0.6
       zod: 4.3.6
 
+  '@ai-sdk/provider-utils@4.0.15(zod@4.3.5)':
+    dependencies:
+      '@ai-sdk/provider': 3.0.8
+      '@standard-schema/spec': 1.1.0
+      eventsource-parser: 3.0.6
+      zod: 4.3.5
+
   '@ai-sdk/provider-utils@4.0.15(zod@4.3.6)':
     dependencies:
       '@ai-sdk/provider': 3.0.8
@@ -15971,6 +16306,130 @@ snapshots:
 
   '@radix-ui/rect@1.1.1': {}
 
+  '@react-email/body@0.2.1(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/button@0.2.1(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/code-block@0.2.1(react@19.2.4)':
+    dependencies:
+      prismjs: 1.30.0
+      react: 19.2.4
+
+  '@react-email/code-inline@0.0.6(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/column@0.0.14(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/components@1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      '@react-email/body': 0.2.1(react@19.2.4)
+      '@react-email/button': 0.2.1(react@19.2.4)
+      '@react-email/code-block': 0.2.1(react@19.2.4)
+      '@react-email/code-inline': 0.0.6(react@19.2.4)
+      '@react-email/column': 0.0.14(react@19.2.4)
+      '@react-email/container': 0.0.16(react@19.2.4)
+      '@react-email/font': 0.0.10(react@19.2.4)
+      '@react-email/head': 0.0.13(react@19.2.4)
+      '@react-email/heading': 0.0.16(react@19.2.4)
+      '@react-email/hr': 0.0.12(react@19.2.4)
+      '@react-email/html': 0.0.12(react@19.2.4)
+      '@react-email/img': 0.0.12(react@19.2.4)
+      '@react-email/link': 0.0.13(react@19.2.4)
+      '@react-email/markdown': 0.0.18(react@19.2.4)
+      '@react-email/preview': 0.0.14(react@19.2.4)
+      '@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      '@react-email/row': 0.0.13(react@19.2.4)
+      '@react-email/section': 0.0.17(react@19.2.4)
+      '@react-email/tailwind': 2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)
+      '@react-email/text': 0.1.6(react@19.2.4)
+      react: 19.2.4
+    transitivePeerDependencies:
+      - react-dom
+
+  '@react-email/container@0.0.16(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/font@0.0.10(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/head@0.0.13(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/heading@0.0.16(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/hr@0.0.12(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/html@0.0.12(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/img@0.0.12(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/link@0.0.13(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/markdown@0.0.18(react@19.2.4)':
+    dependencies:
+      marked: 15.0.12
+      react: 19.2.4
+
+  '@react-email/preview@0.0.14(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      html-to-text: 9.0.5
+      prettier: 3.7.4
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+
+  '@react-email/row@0.0.13(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/section@0.0.17(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
+  '@react-email/tailwind@2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      '@react-email/text': 0.1.6(react@19.2.4)
+      react: 19.2.4
+      tailwindcss: 4.1.18
+    optionalDependencies:
+      '@react-email/body': 0.2.1(react@19.2.4)
+      '@react-email/button': 0.2.1(react@19.2.4)
+      '@react-email/code-block': 0.2.1(react@19.2.4)
+      '@react-email/code-inline': 0.0.6(react@19.2.4)
+      '@react-email/container': 0.0.16(react@19.2.4)
+      '@react-email/heading': 0.0.16(react@19.2.4)
+      '@react-email/hr': 0.0.12(react@19.2.4)
+      '@react-email/img': 0.0.12(react@19.2.4)
+      '@react-email/link': 0.0.13(react@19.2.4)
+      '@react-email/preview': 0.0.14(react@19.2.4)
+
+  '@react-email/text@0.1.6(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+
   '@react-native/assets-registry@0.81.4': {}
 
   '@react-native/assets-registry@0.83.1': {}
@@ -16785,6 +17244,11 @@ snapshots:
 
   '@sec-ant/readable-stream@0.4.1': {}
 
+  '@selderee/plugin-htmlparser2@0.11.0':
+    dependencies:
+      domhandler: 5.0.3
+      selderee: 0.11.0
+
   '@shikijs/core@3.21.0':
     dependencies:
       '@shikijs/types': 3.21.0
@@ -16881,7 +17345,7 @@ snapshots:
   '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
     dependencies:
       '@types/jest': 28.1.8
-      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
+      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       eslint: 8.57.1
       eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -17236,7 +17700,7 @@ snapshots:
       '@types/node': 22.19.6
     optional: true
 
-  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
+  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -17799,6 +18263,14 @@ snapshots:
       '@opentelemetry/api': 1.9.0
       zod: 4.3.6
 
+  ai@6.0.94(zod@4.3.5):
+    dependencies:
+      '@ai-sdk/gateway': 3.0.52(zod@4.3.5)
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
+      '@opentelemetry/api': 1.9.0
+      zod: 4.3.5
+
   ai@6.0.94(zod@4.3.6):
     dependencies:
       '@ai-sdk/gateway': 3.0.52(zod@4.3.6)
@@ -18804,10 +19276,28 @@ snapshots:
       '@babel/runtime': 7.28.6
       csstype: 3.2.3
 
+  dom-serializer@2.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      entities: 4.5.0
+
+  domelementtype@2.3.0: {}
+
   domexception@2.0.1:
     dependencies:
       webidl-conversions: 5.0.0
 
+  domhandler@5.0.3:
+    dependencies:
+      domelementtype: 2.3.0
+
+  domutils@3.2.2:
+    dependencies:
+      dom-serializer: 2.0.0
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+
   dotenv-expand@11.0.7:
     dependencies:
       dotenv: 16.4.7
@@ -18912,6 +19402,8 @@ snapshots:
       ansi-colors: 4.1.3
       strip-ansi: 6.0.1
 
+  entities@4.5.0: {}
+
   entities@6.0.1: {}
 
   entities@7.0.1: {}
@@ -20376,10 +20868,25 @@ snapshots:
 
   html-escaper@2.0.2: {}
 
+  html-to-text@9.0.5:
+    dependencies:
+      '@selderee/plugin-htmlparser2': 0.11.0
+      deepmerge: 4.3.1
+      dom-serializer: 2.0.0
+      htmlparser2: 8.0.2
+      selderee: 0.11.0
+
   html-url-attributes@3.0.1: {}
 
   html-void-elements@3.0.0: {}
 
+  htmlparser2@8.0.2:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      entities: 4.5.0
+
   http-errors@2.0.1:
     dependencies:
       depd: 2.0.0
@@ -21400,6 +21907,8 @@ snapshots:
 
   lan-network@0.1.7: {}
 
+  leac@0.6.0: {}
+
   leven@3.1.0: {}
 
   levn@0.4.1:
@@ -21603,6 +22112,8 @@ snapshots:
 
   markdown-table@3.0.4: {}
 
+  marked@15.0.12: {}
+
   marked@17.0.1: {}
 
   marky@1.3.0: {}
@@ -22762,6 +23273,11 @@ snapshots:
     dependencies:
       entities: 6.0.1
 
+  parseley@0.12.1:
+    dependencies:
+      leac: 0.6.0
+      peberminta: 0.9.0
+
   parseurl@1.3.3: {}
 
   path-browserify@1.0.1: {}
@@ -22794,6 +23310,8 @@ snapshots:
 
   pathe@2.0.3: {}
 
+  peberminta@0.9.0: {}
+
   pend@1.2.0: {}
 
   picocolors@1.1.1: {}
@@ -22947,6 +23465,8 @@ snapshots:
     dependencies:
       parse-ms: 4.0.0
 
+  prismjs@1.30.0: {}
+
   proc-log@4.2.0: {}
 
   progress@2.0.3: {}
@@ -23980,6 +24500,10 @@ snapshots:
       extend-shallow: 2.0.1
       kind-of: 6.0.3
 
+  selderee@0.11.0:
+    dependencies:
+      parseley: 0.12.1
+
   semver@6.3.1: {}
 
   semver@7.5.3:
@@ -25746,6 +26270,8 @@ snapshots:
 
   zod@3.25.76: {}
 
+  zod@4.3.5: {}
+
   zod@4.3.6: {}
 
   zustand@4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):

+ 175 - 0
skills/json-render-react-email/SKILL.md

@@ -0,0 +1,175 @@
+---
+name: json-render-react-email
+description: React Email renderer for json-render that turns JSON specs into HTML or plain-text emails using @react-email/components and @react-email/render. Use when working with @json-render/react-email, building transactional or marketing emails from JSON, creating email catalogs, rendering AI-generated email specs, or when the user mentions react-email, HTML email, or transactional email.
+metadata:
+  tags: react-email, email, json-render, html email, transactional email
+---
+
+# @json-render/react-email
+
+React Email renderer that converts JSON specs into HTML or plain-text email output.
+
+## Quick Start
+
+```typescript
+import { renderToHtml } from "@json-render/react-email";
+import { schema, standardComponentDefinitions } from "@json-render/react-email";
+import { defineCatalog } from "@json-render/core";
+
+const catalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+});
+
+const spec = {
+  root: "html-1",
+  elements: {
+    "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
+    "head-1": { type: "Head", props: {}, children: [] },
+    "body-1": {
+      type: "Body",
+      props: { style: { backgroundColor: "#f6f9fc" } },
+      children: ["container-1"],
+    },
+    "container-1": {
+      type: "Container",
+      props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
+      children: ["heading-1", "text-1"],
+    },
+    "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
+    "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
+  },
+};
+
+const html = await renderToHtml(spec);
+```
+
+## Spec Structure (Element Tree)
+
+Same flat element tree as `@json-render/react`: `root` key plus `elements` map. Root must be `Html`; children of `Html` should be `Head` and `Body`. Use `Container` (e.g. max-width 600px) inside `Body` for client-safe layout.
+
+## Creating a Catalog and Registry
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema, defineRegistry, renderToHtml } from "@json-render/react-email";
+import { standardComponentDefinitions } from "@json-render/react-email/catalog";
+import { Container, Heading, Text } from "@react-email/components";
+import { z } from "zod";
+
+const catalog = defineCatalog(schema, {
+  components: {
+    ...standardComponentDefinitions,
+    Alert: {
+      props: z.object({
+        message: z.string(),
+        variant: z.enum(["info", "success", "warning"]).nullable(),
+      }),
+      slots: [],
+      description: "A highlighted message block",
+    },
+  },
+  actions: {},
+});
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Alert: ({ props }) => (
+      <Container style={{ padding: 16, backgroundColor: "#eff6ff", borderRadius: 8 }}>
+        <Text style={{ margin: 0 }}>{props.message}</Text>
+      </Container>
+    ),
+  },
+});
+
+const html = await renderToHtml(spec, { registry });
+```
+
+## Server-Side Render APIs
+
+| Function | Purpose |
+|----------|---------|
+| `renderToHtml(spec, options?)` | Render spec to HTML email string |
+| `renderToPlainText(spec, options?)` | Render spec to plain-text email string |
+
+`RenderOptions`: `registry`, `includeStandard` (default true), `state` (for `$state` / `$cond`).
+
+## Visibility and State
+
+Supports `visible` conditions, `$state`, `$cond`, repeat (`repeat.statePath`), and the same expression syntax as `@json-render/react`. Use `state` in `RenderOptions` when rendering server-side so expressions resolve.
+
+## Server-Safe Import
+
+Import schema and catalog without React or `@react-email/components`:
+
+```typescript
+import { schema, standardComponentDefinitions } from "@json-render/react-email/server";
+```
+
+## Key Exports
+
+| Export | Purpose |
+|--------|---------|
+| `defineRegistry` | Create type-safe component registry from catalog |
+| `Renderer` | Render spec in browser (e.g. preview); use with `JSONUIProvider` for state/actions |
+| `createRenderer` | Standalone renderer component with state/actions/validation |
+| `renderToHtml` | Server: spec to HTML string |
+| `renderToPlainText` | Server: spec to plain-text string |
+| `schema` | Email element schema |
+| `standardComponents` | Pre-built component implementations |
+| `standardComponentDefinitions` | Catalog definitions (Zod props) |
+
+## Sub-path Exports
+
+| Path | Purpose |
+|------|---------|
+| `@json-render/react-email` | Full package |
+| `@json-render/react-email/server` | Schema and catalog only (no React) |
+| `@json-render/react-email/catalog` | Standard component definitions and types |
+| `@json-render/react-email/render` | Render functions only |
+
+## Standard Components
+
+All components accept a `style` prop (object) for inline styles. Use inline styles for email client compatibility; avoid external CSS.
+
+### Document structure
+
+| Component | Description |
+|-----------|-------------|
+| `Html` | Root wrapper (lang, dir). Children: Head, Body. |
+| `Head` | Email head section. |
+| `Body` | Body wrapper; use `style` for background. |
+
+### Layout
+
+| Component | Description |
+|-----------|-------------|
+| `Container` | Constrain width (e.g. max-width 600px). |
+| `Section` | Group content; table-based for compatibility. |
+| `Row` | Horizontal row. |
+| `Column` | Column in a Row; set width via style. |
+
+### Content
+
+| Component | Description |
+|-----------|-------------|
+| `Heading` | Heading text (as: h1–h6). |
+| `Text` | Body text. |
+| `Link` | Hyperlink (text, href). |
+| `Button` | CTA link styled as button (text, href). |
+| `Image` | Image from URL (src, alt, width, height). |
+| `Hr` | Horizontal rule. |
+
+### Utility
+
+| Component | Description |
+|-----------|-------------|
+| `Preview` | Inbox preview text (inside Html). |
+| `Markdown` | Markdown content as email-safe HTML. |
+
+## Email Best Practices
+
+- Keep width constrained (e.g. Container max-width 600px).
+- Use inline styles or React Email's style props; many clients strip `<style>` blocks.
+- Prefer table-based layout (Section, Row, Column) for broad client support.
+- Use absolute URLs for images; many clients block relative or cid: references in some contexts.
+- Test in multiple clients (Gmail, Outlook, Apple Mail); use a preview tool or Litmus-like service when possible.