Every product team eventually hits the same wall with email: a welcome.html file ballooning into a 1,200-line monolith, copy-pasted styles across templates that never quite match, a marketing tweak that breaks billing emails, and an A/B test that takes a week to ship because the "templating engine" lives in a folder no one wants to touch.
Email is one of the last places in modern product engineering where we still tolerate this. We componentized our UI years ago. We componentized our backends. But our transactional and lifecycle emails are still strings of HTML glued together inside a service.
At Bit, we manage every email template - welcome, billing, organization invites, password resets, notifications - as independent, versioned, composable components. Each piece of an email (a title, a paragraph, a CTA button, a card, a footer) is its own component. Each template (welcome, payment-failed, scope-invite) composes those pieces.
This post walks through how we got there, and how you can do the same.
Why "composable" emails matter
Emails have all the same problems as UI:
- Consistency. Every email needs to look like the same product. The same brand color on the CTA. The same paragraph spacing. The same footer copy and unsubscribe link.
- Reuse. "Action button" exists in 30 templates. "Card with content" exists in 20. The footer exists in all of them.
- Change management. When the brand color changes, or the footer text changes, or the unsubscribe URL changes, you don't want to grep 30 files.
- Testing. You want to preview a template visually, in isolation, with mock data - the same way you preview a React component.
- Ownership. Billing owns billing emails. People owns invite emails. Marketing owns lifecycle emails. They shouldn't all live in one folder owned by no one.
Emails are UI. The fact that they render in Gmail instead of a browser doesn't change the engineering problem. So we apply the same pattern: small, independent components, composed into larger ones, distributed across teams.
The architecture
Our email system is built from three layers, each living in its own namespace:
UI primitives - the small building blocks: email-title, email-paragraph, email-link, email-action, email-card. Each is a component that returns a string of inline-styled HTML.
Templates - composed emails: welcome-email, password-reset-request, billing/payment-failed, org/org-invite. Each imports the primitives it needs and renders a full HTML document.
Aspect - a single email aspect that exposes a slot-based API. Any service can register an email provider (Resend, SES, SendGrid) and any feature can request to send an email through it.
Here is the full layout in our emails scope:
teambit.emails/
├── aspects/
│ ├── email ← the platform aspect (slot + sendEmail API)
│ └── resend ← a provider implementation
├── ui/
│ ├── email-title
│ ├── email-paragraph
│ ├── email-link
│ ├── email-action
│ ├── email-card
│ └── get-started
└── templates/
├── base-email ← the shared layout
├── welcome-email
├── email-confirmation
├── password-reset-request
├── password-reset-confirmed
├── email-notification
├── contact-sales
├── ...
├── billing/
│ ├── payment-success
│ ├── payment-failed
│ └── ...
├── org/
│ ├── org-invite
│ └── ...
└── scope/
└── scope-invite
Every node in that tree is an independently versioned Bit component. email-action can release a v1.2 with a new prop. welcome-email can pick up that release on its own schedule. billing/payment-failed doesn't have to know either exists.
Step 1 - A UI primitive
Let's start at the bottom. Here is the entire implementation of EmailAction - the CTA button used across every template:
export type EmailActionProps = {
priority?: 'primary' | 'secondary';
value?: string;
link?: string;
marginBottom?: string;
};
export function EmailAction({
priority = 'primary',
value,
link,
marginBottom,
}: EmailActionProps) {
const bg = priority === 'primary' ? '#6c5ce7' : '#ffffff';
const color = priority === 'primary' ? '#ffffff' : '#1d1c1d';
const border = priority === 'primary' ? 'none' : '1px solid #e0e0e0';
return `
<div style="margin-bottom: ${marginBottom || '0'}; text-align: left;">
<a
href="${link}"
target="_blank"
style="
display: inline-block;
padding: 12px 24px;
background: ${bg};
color: ${color};
border: ${border};
border-radius: 6px;
font-weight: 600;
text-decoration: none;
font-family: 'Lato', 'Helvetica', 'Arial';
"
>${value}</a>
</div>
`;
}
A few things worth noticing:
- It returns a string. Email is HTML - there is no DOM, no React reconciler. We're building strings, but with the same component model.
- All styles are inline. Email clients (especially Gmail and Outlook) strip
<style>tags and ignore most CSS files. The component is the only place styles can live, so the component owns them. - The API is typed.
priority,value,link,marginBottomare all explicit props. Consumers get autocomplete and type errors, exactly as if this were a React component. - It has no dependencies on other email components.
email-paragraph, email-title, email-link, email-card follow the same shape: typed props in, HTML string out.
Step 2 - A shared base layout
Every email needs the same outer shell: the <html> document, the brand logo at the top, the footer with the unsubscribe link. That is base-email:
import { Footer } from './footer';
import type { FooterProps } from './footer';
export type BaseEmailProps = {
children?: string;
} & FooterProps;
export function BaseEmail({ children, unsubscribeLink }: BaseEmailProps) {
return `<html lang="en">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<div style="background: #f7f7f7; font-family: 'Lato', 'Helvetica', 'Arial'; font-size: 16px; line-height: 16px; color: #1d1c1d;">
<div style="max-width: 600px; margin: 14px auto; padding: 10px;">
<div style="width: 100%; margin-bottom: 32px; text-align: left;">
<a href="https://bit.cloud" target="_blank" style="text-decoration: none;">
<img src="https://storage.googleapis.com/static.bit.dev/brands/bit-logo-min.png" style="width: 60px; height: 60px;" />
</a>
</div>
${children !== undefined ? children : ''}${Footer({ unsubscribeLink })}
</div>
</div>
</body>
</html>`;
}
BaseEmail takes children as a string - the rendered body of the specific template - and wraps it in the brand layout. The footer is another component injected by the base. Update the brand color or the footer copy here once, and every template inherits it on its next install.
Step 3 - Composing a template
Now the fun part. Here's WelcomeEmail - the actual email that goes to every new Bit Cloud user:
import { BaseEmail } from '@teambit/emails.templates.base-email';
import { EmailAction } from '@teambit/emails.ui.email-action';
import { EmailTitle } from '@teambit/emails.ui.email-title';
import { EmailParagraph } from '@teambit/emails.ui.email-paragraph';
import { GetStarted } from './get-started';
export type WelcomeEmailProps = {
username: string;
};
export function WelcomeEmail({ username }: WelcomeEmailProps) {
return BaseEmail({
children: `
<div style="background: #ffffff;">
<img src="https://static.bit.dev/emails/welcome-to-bit-cloud.png" width="100%" height="auto">
<div style="padding: 32px 20px 64px;">
${EmailParagraph({
text: `Hey <span style="font-weight: bold;">${username},</span>`,
marginBottom: '16px',
})}${EmailParagraph({
text: 'Welcome to Bit Cloud, the AI-powered platform for building production-grade software.',
})}${EmailTitle({ title: 'Build from Scratch', bold: true })}${EmailParagraph({
text: 'Describe your app with a prompt. Hope AI builds it, validates it, and deploys it instantly.',
})}${EmailTitle({ title: 'Modernize Legacy Code', bold: true })}${EmailParagraph({
text: 'Generate modern packages from your legacy projects. Renew your stack one component at a time.',
})}${EmailAction({
value: 'Prompt for Production',
link: 'https://bit.cloud/hope',
marginBottom: '32px',
})}${GetStarted()}
</div>
</div>
`,
});
}
That's the entire email. Notice what isn't there: no inline styles, no HTML boilerplate, no footer markup, no logo URL. All of that lives in BaseEmail and the primitives. This file is purely the content of the welcome email - the words, the structure, the order of the sections.
Compare that to a typical 800-line welcome.html and the difference is immediate. Three months from now, when the marketing team wants to add a section about a new feature, they will edit this file. No one will need to touch base-email, email-action, or email-title.
Step 4 - Sending it through a service
So far everything is just functions that return HTML strings. The last piece is the runtime: something that takes a rendered template and actually puts it in someone's inbox.
We keep this as a small, standalone email service that expose a single sendEmail method that any feature in the app can call.
And anywhere in the app - billing, onboarding, notifications - you render a template and hand it to the service:
import { WelcomeEmail } from '@teambit/emails.templates.welcome-email';
const html = WelcomeEmail({ username: 'jane' });
await emailService.sendEmail({
to: ['jane@example.com'],
from: 'hello@bit.cloud',
subject: 'Welcome to Bit Cloud',
html,
});
Three things are worth pointing out about this seam:
- Templates don't know which provider sends them.
WelcomeEmailreturns a string. It has no idea Resend exists. - Providers don't know which templates they send.
EmailProvideronly seesEmailData. To it, every email looks the same. - The service is the only thing that knows about both. Swap Resend for SES tomorrow by registering a different provider - no template has to change.
This is dependency inversion in plain English: the templates and the providers depend on a small interface in the middle, never on each other. It's the same pattern you'd use for a payments service, a storage service, or a search service. Email is no different.
If you're building this inside a Bit Harmony platform, this "service" is exactly what we call an aspect - same idea, just with a slot-based registration API so providers can plug themselves in automatically. If that's not your world, a plain class with a registerProvider method works just as well.
What this gets you in practice
A few concrete wins:
Brand changes are one snap. When we updated our primary brand color, we changed it in email-action and snapped a new version. Every template automatically picked it up on the next install. No grep, no missed templates, no inconsistent buttons.
Teams own their templates. Billing owns templates/billing/*. Organization owns templates/org/*. They don't ask permission to ship a copy change. They snap their own component.
Templates are previewable. Each template is a Bit component, which means it has compositions. Drop it on the dev server (bit start) and you get a live preview with mock props - the same workflow as previewing a React component. We can iterate on the welcome email without sending a single real message.
Type-safety end to end. WelcomeEmail({ username }) is a typed function call. If the template starts requiring a new prop, the call site fails to compile. No more "I forgot to pass the user's first name and the email said Hi undefined," stories.
Versioned and diffable. Every change is a snap. Every snap has a diff. We can see who changed base-email last week and why. We can roll back a single template without touching anything else.
Reuse across products. The same email-action, email-card, and email-paragraph we use in transactional emails are also used in marketing emails, in admin notifications, in our agent-driven hope-finished-generating email. One vocabulary, many products.
How to start, in your own workspace
You have two ways to get this running.
The fast way — ask Hope to build it for you
Hope AI is the AI builder on Bit Cloud. Open a new chat and try a prompt like:
Create a composable email system in a new emails scope. Use the frontend.html/envs/html-cjs-env environment for every component. I want primitive components for title, paragraph, link, and a CTA action button — each returning inline-styled HTML strings. Add a base-email template that wraps content in our brand layout with a footer. Then create three templates: welcome-email, password-reset, and payment-failed.
Hope will plan the scope, generate every component with the right environment, wire the dependencies, and let you preview each template visually before you snap. From there you can ask follow-ups — "add a billing/subscription-cancelled template", "swap the brand color across all primitives", "add an SES provider" — and Hope will handle the changes the same way.
Here's what Hope produced from a single prompt:
The manual way — step by step
If you'd rather build it yourself first to feel the pattern, here's the full path with the commands you'll actually run.
1. Set up the workspace and enable the HTML env
Email components aren't React components — they render to plain HTML strings, server-side, and never touch the DOM. We use frontend.html/envs/html-cjs-env, which compiles to CommonJS so the templates can be required straight from any Node service that needs to send mail.
# Initialize a workspace if you don't have one
bit init my-emails-workspace
cd my-emails-workspace
# Install the HTML env so its generator templates become available
bit install @frontend/html.envs.html-cjs-env
Then add the env to the generator section of your workspace.jsonc:
"teambit.generator/generator":{"envs":["frontend.html/envs/html-cjs-env"]}
Run bit templates to confirm — you should see the html template listed.
Configure your scope, every Bit component lives in a scope — that's what shows up in the package name (@your-org/emails.ui.email-title) and what teams use to find your work. Set it once in workspace.jsonc and every new component you create will belong to it automatically:
"teambit.workspace/workspace":{...//everynewcomponentwillbecreatedunderthisscopebydefault"defaultScope":"your-org.emails"},
If you don't have a scope yet, create one at bit.cloud/create-scope.
2. Create your primitives
Start with three: a title, a paragraph, a CTA button. Don't try to model every possible piece up front — add new primitives only when a template actually needs one.
bit create html ui/email-title --env frontend.html/envs/html-cjs-env
bit create html ui/email-paragraph --env frontend.html/envs/html-cjs-env
bit create html ui/email-action --env frontend.html/envs/html-cjs-env
Each one is a tiny function: typed props in, inline-styled HTML string out. Push every style into the primitive itself — Gmail and Outlook strip <style> tags, so the component is the only place styles can live.
3. Build a base layout
One component that renders the document, the brand header, and the footer. It takes children as a string.
bit create html templates/base-email --env frontend.html/envs/html-cjs-env
4. Write your first template
Compose the primitives inside the base. Resist the urge to put inline styles here — this file is purely the content of the email.
bit create html templates/welcome-email --env frontend.html/envs/html-cjs-env
5. Preview, snap, and reuse
# Preview every template visually with mock props
bit start
# When you're happy, validate, snap, and export
bit validate
bit snap --message "initial composable email system"
bit export
Once the first template is in place, the second one is a 10-minute job. By template five, you'll wonder how you ever did it differently.
You don't need a "framework" for this. You don't need MJML, Maizzle, or React Email. The pattern is the point: small components, composed into larger ones, versioned independently, owned by the teams that care about them.
Conclusion
Email is the last unrefactored corner of most product codebases. It doesn't have to be. The same composability that transformed how we ship UI works just as well for what we ship to inboxes. The result is faster iteration, fewer regressions, clearer ownership, and emails that actually look like they came from the same product.
Treat your emails like components. Your future self - and your marketing team - will thank you.
Written by Josh Kuttler, Software Engineer at Bit — building the composable web.
Let's connect — I'd love to hear how you're handling emails (or anything composable) in your stack:
- 🐦 X: @JoshKuttler
- 💼 LinkedIn: josh-kuttler
If this post helped, a ❤️ or a follow on dev.to means a lot — and lets me know what to write about next.