diff --git a/packages/demo/src/App.tsx b/packages/demo/src/App.tsx
index 967227d3..b5a04528 100644
--- a/packages/demo/src/App.tsx
+++ b/packages/demo/src/App.tsx
@@ -3,6 +3,7 @@ import Hero from "./sections/Hero";
import QuickStart from "./sections/QuickStart";
import TemplateShowcase from "./sections/TemplateShowcase";
import RenderModeDemo from "./sections/RenderModeDemo";
+import NewsletterArchiveDemo from "./sections/NewsletterArchiveDemo";
import FeatureComparison from "./sections/FeatureComparison";
import ComponentGallery from "./sections/ComponentGallery";
import CTABanner from "./sections/CTABanner";
@@ -43,6 +44,7 @@ export default function App() {
+
diff --git a/packages/demo/src/hooks/useRenderTemplate.ts b/packages/demo/src/hooks/useRenderTemplate.ts
index afffae97..bbca3942 100644
--- a/packages/demo/src/hooks/useRenderTemplate.ts
+++ b/packages/demo/src/hooks/useRenderTemplate.ts
@@ -23,7 +23,7 @@ export function useRenderTemplate(templateId: string): RenderResult {
let plainText = "";
try {
- const parts = renderToHtmlParts(element, { mode: "email" });
+ const parts = renderToHtmlParts(element, { mode: entry.mode ?? "email" });
head = parts.head;
html = parts.body;
} catch (e) {
diff --git a/packages/demo/src/sections/NewsletterArchiveDemo.tsx b/packages/demo/src/sections/NewsletterArchiveDemo.tsx
new file mode 100644
index 00000000..6f6c3b0f
--- /dev/null
+++ b/packages/demo/src/sections/NewsletterArchiveDemo.tsx
@@ -0,0 +1,97 @@
+import { useMemo } from "react";
+import { renderToHtmlParts } from "@unlayer/react-elements";
+import { newsletterTemplate, type NewsletterOutput } from "../templates/NewsletterDigest";
+import SectionHeader from "../components/SectionHeader";
+import DeviceFrame from "../components/DeviceFrame";
+import CodeBlock from "../components/CodeBlock";
+
+const snippet = `// One tree, two outputs — only the wrapper changes.
+function newsletterTemplate(output: "email" | "web") {
+ const Wrapper = output === "web" ? Page : Email;
+ return (
+
+ {/* ...masthead, featured article, article list, footer... */}
+
+ );
+}
+
+// Send this:
+const email = renderToHtml(newsletterTemplate("email")); // tables, Outlook-safe
+// Publish this at /archive/issue-47:
+const page = renderToHtml(newsletterTemplate("web")); // div + flexbox`;
+
+const outputs: { id: NewsletterOutput; wrapper: string; label: string; blurb: string }[] = [
+ {
+ id: "email",
+ wrapper: "Email",
+ label: "In the inbox",
+ blurb: "Nested tables with inlined styles — what Outlook and Gmail need.",
+ },
+ {
+ id: "web",
+ wrapper: "Page",
+ label: "In the browser",
+ blurb: "Semantic divs and flexbox — the archive page you link from “View in browser”.",
+ },
+];
+
+export default function NewsletterArchiveDemo() {
+ const rendered = useMemo(
+ () =>
+ outputs.map((output) => {
+ try {
+ const { head, body } = renderToHtmlParts(newsletterTemplate(output.id), {
+ mode: output.id,
+ });
+ return { ...output, head, body };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ return { ...output, head: "", body: `` };
+ }
+ }),
+ []
+ );
+
+ return (
+
+ {/* Background */}
+
+
+
+
+
+
+
+
+ {rendered.map((output) => (
+
+
+
+ {`<${output.wrapper}>`}
+
+ {output.label}
+
+
{output.blurb}
+
+
+ ))}
+
+
+
+
+
+ No second template to keep in sync. Swap {""} for{" "}
+ {""} and the same content ships to both the inbox
+ and the web — see the Newsletter Web Archive entry above
+ for the full source.
+
+
+
+
+
+ );
+}
diff --git a/packages/demo/src/templates/NewsletterDigest.tsx b/packages/demo/src/templates/NewsletterDigest.tsx
index 4ebba1cd..ac2ab6b8 100644
--- a/packages/demo/src/templates/NewsletterDigest.tsx
+++ b/packages/demo/src/templates/NewsletterDigest.tsx
@@ -1,6 +1,7 @@
import type { ReactElement } from "react";
import {
Email,
+ Page,
Row,
Column,
Paragraph,
@@ -21,9 +22,24 @@ const uiFont = {
value: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
};
-export default function NewsletterDigest(): ReactElement {
+/** Which output this newsletter is being rendered for. */
+export type NewsletterOutput = "email" | "web";
+
+/**
+ * The newsletter, wrapper-agnostic.
+ *
+ * Everything inside the wrapper is identical for both outputs — only the root
+ * component changes:
+ * → table-based HTML for inboxes
+ * → div/flexbox HTML for the browser ("view in browser" archive)
+ *
+ * See NewsletterWebArchive.tsx for the web variant.
+ */
+export function newsletterTemplate(output: NewsletterOutput): ReactElement {
+ const Wrapper = output === "web" ? Page : Email;
+
return (
-
+ {/* The only content that differs between the two outputs: the inbox
+ copy links out to the archive, the archive links back to signup. */}
Subscribe · Browse the archive · RSS'
+ : 'Unsubscribe · View in browser · Update preferences'
+ }
fontSize="12px"
color="#999999"
textAlign="center"
@@ -288,6 +310,11 @@ export default function NewsletterDigest(): ReactElement {
/>
-
+
);
}
+
+/** The newsletter as an email — table-based HTML for inboxes. */
+export default function NewsletterDigest(): ReactElement {
+ return newsletterTemplate("email");
+}
diff --git a/packages/demo/src/templates/NewsletterWebArchive.tsx b/packages/demo/src/templates/NewsletterWebArchive.tsx
new file mode 100644
index 00000000..1326b8ea
--- /dev/null
+++ b/packages/demo/src/templates/NewsletterWebArchive.tsx
@@ -0,0 +1,22 @@
+import type { ReactElement } from "react";
+import { newsletterTemplate } from "./NewsletterDigest";
+
+/**
+ * Newsletter web archive — the "view in browser" page for the newsletter email.
+ *
+ * This is the *same component tree* as NewsletterDigest. Nothing is duplicated:
+ * `newsletterTemplate()` swaps only the root wrapper.
+ *
+ * newsletterTemplate("email") → → tables, inlined for Outlook/Gmail
+ * newsletterTemplate("web") → → div/flexbox, responsive browser HTML
+ *
+ * The rows, columns, headings, images, and buttons in between are byte-identical.
+ * That is the point: send the email, then publish the archive page from the same
+ * source — no second template to keep in sync.
+ *
+ * Rendering it is the usual one-liner:
+ * renderToHtml(…) // or renderToHtmlParts() to own the shell
+ */
+export default function NewsletterWebArchive(): ReactElement {
+ return newsletterTemplate("web");
+}
diff --git a/packages/demo/src/templates/index.ts b/packages/demo/src/templates/index.ts
index e39035fe..35495af0 100644
--- a/packages/demo/src/templates/index.ts
+++ b/packages/demo/src/templates/index.ts
@@ -7,6 +7,7 @@ import OrderConfirmation from "./OrderConfirmation";
import ReviewRequest from "./ReviewRequest";
import PasswordReset from "./PasswordReset";
import NewsletterDigest from "./NewsletterDigest";
+import NewsletterWebArchive from "./NewsletterWebArchive";
import AbandonedCart from "./AbandonedCart";
import ShippingUpdate from "./ShippingUpdate";
import ProductLaunch from "./ProductLaunch";
@@ -21,6 +22,7 @@ import orderSource from "./OrderConfirmation.tsx?raw";
import reviewSource from "./ReviewRequest.tsx?raw";
import passwordSource from "./PasswordReset.tsx?raw";
import newsletterSource from "./NewsletterDigest.tsx?raw";
+import newsletterArchiveSource from "./NewsletterWebArchive.tsx?raw";
import abandonedCartSource from "./AbandonedCart.tsx?raw";
import shippingSource from "./ShippingUpdate.tsx?raw";
import productLaunchSource from "./ProductLaunch.tsx?raw";
@@ -101,6 +103,17 @@ export const templates: TemplateEntry[] = [
component: NewsletterDigest,
sourceCode: newsletterSource,
},
+ {
+ id: "newsletter-web-archive",
+ name: "Newsletter Web Archive",
+ description: "The same newsletter tree rendered with as a browser archive page",
+ category: "newsletter",
+ inspiration: "Substack",
+ colorAccent: "#e85d04",
+ component: NewsletterWebArchive,
+ sourceCode: newsletterArchiveSource,
+ mode: "web",
+ },
{
id: "abandoned-cart",
name: "Abandoned Cart",