diff --git a/.changeset/cute-pigs-lay.md b/.changeset/cute-pigs-lay.md
new file mode 100644
index 0000000000..4e5ec661a6
--- /dev/null
+++ b/.changeset/cute-pigs-lay.md
@@ -0,0 +1,5 @@
+---
+"@react-email/preview-server": minor
+---
+
+Integrate with Templates API so users can easily turn React Email templates into actual Resend templates
diff --git a/benchmarks/preview-server/src/utils/sleep.ts b/benchmarks/preview-server/src/utils/sleep.ts
new file mode 100644
index 0000000000..0d7f188e17
--- /dev/null
+++ b/benchmarks/preview-server/src/utils/sleep.ts
@@ -0,0 +1,3 @@
+export function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/packages/preview-server/package.json b/packages/preview-server/package.json
index db43c2784a..6ec1182f90 100644
--- a/packages/preview-server/package.json
+++ b/packages/preview-server/package.json
@@ -41,12 +41,14 @@
"log-symbols": "4.1.0",
"module-punycode": "npm:punycode@2.3.1",
"next": "16.0.1",
+ "next-safe-action": "8.0.11",
"node-html-parser": "7.0.1",
"ora": "5.4.1",
"pretty-bytes": "6.1.1",
"prism-react-renderer": "2.4.1",
"react": "19.0.0",
"react-dom": "19.0.0",
+ "resend": "6.4.0",
"sharp": "0.34.4",
"socket.io-client": "4.8.1",
"sonner": "2.0.3",
@@ -56,7 +58,7 @@
"tailwind-merge": "3.2.0",
"tailwindcss": "3.4.0",
"use-debounce": "10.0.4",
- "zod": "3.24.3"
+ "zod": "4.1.12"
},
"devDependencies": {
"@react-email/components": "workspace:*",
diff --git a/packages/preview-server/src/actions/export-single-template.ts b/packages/preview-server/src/actions/export-single-template.ts
new file mode 100644
index 0000000000..eb6f0ccd10
--- /dev/null
+++ b/packages/preview-server/src/actions/export-single-template.ts
@@ -0,0 +1,36 @@
+'use server';
+
+import { Resend } from 'resend';
+import { z } from 'zod';
+import { resendApiKey } from '../app/env';
+import { baseActionClient } from './safe-action';
+
+export const exportSingleTemplate = baseActionClient
+ .metadata({
+ actionName: 'exportSingleTemplate',
+ })
+ .inputSchema(
+ z.object({
+ name: z.string(),
+ html: z.string(),
+ }),
+ )
+ .action(async ({ parsedInput }) => {
+ const resend = new Resend(resendApiKey);
+
+ const response = await resend.templates.create({
+ name: parsedInput.name,
+ html: parsedInput.html,
+ });
+
+ if (response.error) {
+ console.error('Error creating single template', response.error);
+ return { name: parsedInput.name, status: 'failed' as const };
+ }
+
+ return {
+ name: parsedInput.name,
+ status: 'succeeded' as const,
+ id: response.data.id,
+ };
+ });
diff --git a/packages/preview-server/src/actions/safe-action.ts b/packages/preview-server/src/actions/safe-action.ts
new file mode 100644
index 0000000000..25457803dd
--- /dev/null
+++ b/packages/preview-server/src/actions/safe-action.ts
@@ -0,0 +1,15 @@
+import {
+ createSafeActionClient,
+ DEFAULT_SERVER_ERROR_MESSAGE,
+} from 'next-safe-action';
+import { z } from 'zod';
+
+export const baseActionClient = createSafeActionClient({
+ defineMetadataSchema() {
+ return z.object({ actionName: z.string() });
+ },
+ handleServerError(error, options) {
+ console.error(`Action error: ${options.metadata.actionName}`, error);
+ return DEFAULT_SERVER_ERROR_MESSAGE;
+ },
+});
diff --git a/packages/preview-server/src/app/env.ts b/packages/preview-server/src/app/env.ts
index ef3f9d5437..d16b317581 100644
--- a/packages/preview-server/src/app/env.ts
+++ b/packages/preview-server/src/app/env.ts
@@ -8,6 +8,9 @@ export const previewServerLocation = process.env.PREVIEW_SERVER_LOCATION!;
export const emailsDirectoryAbsolutePath =
process.env.EMAILS_DIR_ABSOLUTE_PATH!;
+/** ONLY ACCESSIBLE ON THE SERVER */
+export const resendApiKey = process.env.RESEND_API_KEY;
+
export const isBuilding = process.env.NEXT_PUBLIC_IS_BUILDING === 'true';
export const isPreviewDevelopment =
diff --git a/packages/preview-server/src/app/preview/[...slug]/page.tsx b/packages/preview-server/src/app/preview/[...slug]/page.tsx
index f0d6456c1c..36f0674da5 100644
--- a/packages/preview-server/src/app/preview/[...slug]/page.tsx
+++ b/packages/preview-server/src/app/preview/[...slug]/page.tsx
@@ -12,10 +12,15 @@ import { Toolbar } from '../../../components/toolbar';
import type { LintingRow } from '../../../components/toolbar/linter';
import type { SpamCheckingResult } from '../../../components/toolbar/spam-assassin';
import { PreviewProvider } from '../../../contexts/preview';
+import { ToolbarProvider } from '../../../contexts/toolbar';
import { getEmailsDirectoryMetadata } from '../../../utils/get-emails-directory-metadata';
import { getLintingSources, loadLintingRowsFrom } from '../../../utils/linting';
import { loadStream } from '../../../utils/load-stream';
-import { emailsDirectoryAbsolutePath, isBuilding } from '../../env';
+import {
+ emailsDirectoryAbsolutePath,
+ isBuilding,
+ resendApiKey,
+} from '../../env';
import Preview from './preview';
export const dynamicParams = true;
@@ -132,11 +137,13 @@ This is most likely not an issue with the preview server. Maybe there was a typo
-
+ 0}>
+
+
diff --git a/packages/preview-server/src/components/icons/icon-cloud-alert.tsx b/packages/preview-server/src/components/icons/icon-cloud-alert.tsx
new file mode 100644
index 0000000000..6843ab45c7
--- /dev/null
+++ b/packages/preview-server/src/components/icons/icon-cloud-alert.tsx
@@ -0,0 +1,18 @@
+import type { IconProps } from './icon-base';
+import { IconBase } from './icon-base';
+
+export const IconCloudAlert = (props: IconProps) => (
+
+
+
+
+
+);
+
+IconCloudAlert.displayName = 'IconCloudAlert';
diff --git a/packages/preview-server/src/components/icons/icon-cloud-check.tsx b/packages/preview-server/src/components/icons/icon-cloud-check.tsx
new file mode 100644
index 0000000000..7a57e0dbc2
--- /dev/null
+++ b/packages/preview-server/src/components/icons/icon-cloud-check.tsx
@@ -0,0 +1,17 @@
+import type { IconProps } from './icon-base';
+import { IconBase } from './icon-base';
+
+export const IconCloudCheck = (props: IconProps) => (
+
+
+
+
+);
+
+IconCloudCheck.displayName = 'IconCloudCheck';
diff --git a/packages/preview-server/src/components/icons/icon-loader.tsx b/packages/preview-server/src/components/icons/icon-loader.tsx
new file mode 100644
index 0000000000..5cc5f79e83
--- /dev/null
+++ b/packages/preview-server/src/components/icons/icon-loader.tsx
@@ -0,0 +1,16 @@
+import type { IconProps } from './icon-base';
+import { IconBase } from './icon-base';
+
+export const IconLoader = (props: IconProps) => (
+
+
+
+);
+
+IconLoader.displayName = 'IconLoader';
diff --git a/packages/preview-server/src/components/toolbar.tsx b/packages/preview-server/src/components/toolbar.tsx
index 3cd7e1d1fc..9d6c5971df 100644
--- a/packages/preview-server/src/components/toolbar.tsx
+++ b/packages/preview-server/src/components/toolbar.tsx
@@ -1,4 +1,5 @@
'use client';
+
import * as Tabs from '@radix-ui/react-tabs';
import { LayoutGroup } from 'framer-motion';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
@@ -6,13 +7,16 @@ import * as React from 'react';
import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility';
import { isBuilding } from '../app/env';
import { usePreviewContext } from '../contexts/preview';
+import { useToolbarContext } from '../contexts/toolbar';
import { cn } from '../utils';
+import CodeSnippet from './code-snippet';
import { IconArrowDown } from './icons/icon-arrow-down';
import { IconCheck } from './icons/icon-check';
import { IconInfo } from './icons/icon-info';
import { IconReload } from './icons/icon-reload';
import { Compatibility, useCompatibility } from './toolbar/compatibility';
import { Linter, type LintingRow, useLinter } from './toolbar/linter';
+import { ResendIntegration } from './toolbar/resend';
import {
SpamAssassin,
type SpamCheckingResult,
@@ -21,10 +25,15 @@ import {
import { ToolbarButton } from './toolbar/toolbar-button';
import { useCachedState } from './toolbar/use-cached-state';
-export type ToolbarTabValue = 'linter' | 'compatibility' | 'spam-assassin';
+export type ToolbarTabValue =
+ | 'linter'
+ | 'compatibility'
+ | 'spam-assassin'
+ | 'resend';
export const useToolbarState = () => {
const searchParams = useSearchParams();
+
const activeTab = (searchParams.get('toolbar-panel') ?? undefined) as
| ToolbarTabValue
| undefined;
@@ -57,6 +66,8 @@ const ToolbarInner = ({
const searchParams = useSearchParams();
const router = useRouter();
+ const { hasSetupResendIntegration } = useToolbarContext();
+
const { activeTab, toggled } = useToolbarState();
const setActivePanelValue = (newValue: ToolbarTabValue | undefined) => {
@@ -155,6 +166,11 @@ const ToolbarInner = ({
Spam
+
+
+ Resend
+
+
- {isBuilding ? null : (
+ {isBuilding || activeTab === 'resend' ? null : (
{
if (activeTab === undefined) {
setActivePanelValue('linter');
@@ -192,7 +210,7 @@ const ToolbarInner = ({
size={24}
className={cn({
'opacity-60 animate-spin-fast':
- lintLoading || spamLoading,
+ lintLoading || spamLoading || compatibilityLoading,
})}
/>
@@ -261,6 +279,22 @@ const ToolbarInner = ({
)}
+
+ {hasSetupResendIntegration ? (
+
+ ) : (
+
+ Connect to Resend
+
+ Run email resend setup re_xxxxxx{' '}
+ to connect your Resend account and refresh.
+
+
+ )}
+
@@ -326,11 +360,11 @@ interface ToolbarProps {
serverCompatibilityResults: CompatibilityCheckingResult[] | undefined;
}
-export const Toolbar = ({
+export function Toolbar({
serverLintingRows,
serverSpamCheckingResult,
serverCompatibilityResults,
-}: ToolbarProps) => {
+}: ToolbarProps) {
const { emailPath, emailSlug, renderedEmailMetadata } = usePreviewContext();
if (renderedEmailMetadata === undefined) return null;
@@ -348,4 +382,4 @@ export const Toolbar = ({
serverCompatibilityResults={serverCompatibilityResults}
/>
);
-};
+}
diff --git a/packages/preview-server/src/components/toolbar/resend.tsx b/packages/preview-server/src/components/toolbar/resend.tsx
new file mode 100644
index 0000000000..de78f3599c
--- /dev/null
+++ b/packages/preview-server/src/components/toolbar/resend.tsx
@@ -0,0 +1,225 @@
+import { useAction } from 'next-safe-action/hooks';
+import { useState } from 'react';
+import { exportSingleTemplate } from '../../actions/export-single-template';
+import { getEmailPathFromSlug } from '../../actions/get-email-path-from-slug';
+import { renderEmailByPath } from '../../actions/render-email-by-path';
+import { useEmails } from '../../contexts/emails';
+import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
+import { sleep } from '../../utils/sleep';
+import { Button } from '../button';
+import { IconCloudAlert } from '../icons/icon-cloud-alert';
+import { IconCloudCheck } from '../icons/icon-cloud-check';
+import { IconLoader } from '../icons/icon-loader';
+import { Results } from './results';
+
+export interface ResendStatus {
+ hasApiKey: boolean;
+ error: string | null;
+}
+
+interface ResendItem {
+ status: 'uploading' | 'failed' | 'succeeded';
+ name: string;
+ id?: string;
+}
+
+type ResendIntegrationProps = {
+ emailSlug: string;
+ htmlMarkup: string;
+};
+
+export function ResendIntegration({
+ emailSlug,
+ htmlMarkup,
+}: ResendIntegrationProps) {
+ const { emailsDirectoryMetadata } = useEmails();
+ const [items, setItems] = useState([]);
+ const [isBulkProcessing, setIsBulkProcessing] = useState(false);
+
+ const { execute: exportSingle, isPending: isExportSinglePending } = useAction(
+ exportSingleTemplate,
+ {
+ onSuccess: ({ data }) => setItems([data]),
+ },
+ );
+
+ const { executeAsync: exportSingleAsync } = useAction(exportSingleTemplate);
+
+ const getAllDirectories = (metadata: EmailsDirectory): EmailsDirectory[] => {
+ const result = [metadata];
+ for (const subDir of metadata.subDirectories) {
+ result.push(...getAllDirectories(subDir));
+ }
+ return result;
+ };
+
+ const loading = isExportSinglePending || isBulkProcessing;
+
+ if (items.length === 0 && !loading) {
+ return (
+
+
+ Upload to Resend
+
+
+ Import your email using the Templates API.
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {items.map((item, index) => (
+
+
+ {item.status === 'uploading' && (
+
+
+ {item.name}
+
+ )}
+ {item.status === 'failed' && (
+
+
+ {item.name}
+
+ )}
+ {item.status === 'succeeded' && (
+
+
+ {item.name}
+
+ )}
+
+
+ {item.status === 'uploading'
+ ? 'Uploading...'
+ : item.status === 'failed'
+ ? 'Failed to upload. Try again.'
+ : 'Template uploaded successfully.'}
+
+
+ {item.status === 'succeeded' && (
+
+ Open in Resend ↗
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/preview-server/src/components/topbar/view-size-controls.tsx b/packages/preview-server/src/components/topbar/view-size-controls.tsx
index e620e85901..cd647aa34a 100644
--- a/packages/preview-server/src/components/topbar/view-size-controls.tsx
+++ b/packages/preview-server/src/components/topbar/view-size-controls.tsx
@@ -80,7 +80,7 @@ export const ViewSizeControls = ({
return (
{VIEW_PRESETS.map((preset) => (
-
+