When your product expands from one Next.js app to a monorepo with multiple frontends (marketing site, app, admin dashboard), you face a choice: duplicate email templates across workspaces, or build a shared email package. Most teams pick duplication first, then regret it when they need to update the password reset template in three places.
This guide covers four proven patterns for sharing React Email templates across monorepo workspaces: shared packages, template inheritance, brand variants, and environment-aware rendering.
Pattern 1: Shared email package
The cleanest approach: extract all email templates into a dedicated workspace package that other apps import. Works with npm workspaces, pnpm workspaces, Yarn workspaces, and Turborepo.
monorepo/
├── apps/
│ ├── web/ # Main SaaS app
│ ├── marketing/ # Landing pages
│ └── admin/ # Internal dashboard
└── packages/
└── emails/ # Shared email package
├── package.json
├── src/
│ ├── templates/
│ │ ├── password-reset.tsx
│ │ ├── welcome.tsx
│ │ └── invoice.tsx
│ ├── components/
│ │ ├── base-layout.tsx
│ │ └── button.tsx
│ └── index.ts
└── tsconfig.jsonThe packages/emails workspace is a standalone package that exports templates and components. Each app imports only what it needs.
Setting up the email package
{
"name": "@acme/emails",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"@react-email/components": "^0.0.25",
"react": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"typescript": "^5.7.2"
}
}Export templates from a central barrel file for clean imports across apps:
// Export all templates
export { PasswordResetEmail } from "./templates/password-reset";
export { WelcomeEmail } from "./templates/welcome";
export { InvoiceEmail } from "./templates/invoice";
// Export shared components
export { BaseLayout } from "./components/base-layout";
export { Button } from "./components/button";
// Export types
export type { PasswordResetProps } from "./templates/password-reset";
export type { WelcomeProps } from "./templates/welcome";Using templates in apps
Apps import the shared package and render templates with their own data:
import { PasswordResetEmail } from "@acme/emails";
import { render } from "@react-email/render";
import { resend } from "@/lib/resend";
export async function POST(request: Request) {
const { email, token } = await request.json();
const html = await render(
<PasswordResetEmail
resetUrl={`https://app.acme.com/reset?token=${token}`}
expiresInMinutes={30}
/>
);
await resend.emails.send({
from: "noreply@acme.com",
to: email,
subject: "Reset your password",
html,
});
return Response.json({ success: true });
}package.json with "@acme/emails": "workspace:*" (pnpm/Yarn) or reference it in your workspace config (npm workspaces).Pattern 2: Multi-brand variants
If you run multiple products under different brands (e.g., a main SaaS and a white-label version), pass brand config as props instead of duplicating templates.
type Brand = {
name: string;
logoUrl: string;
primaryColor: string;
fromDomain: string;
};
type BaseLayoutProps = {
brand: Brand;
previewText: string;
children: React.ReactNode;
};
export function BaseLayout({ brand, previewText, children }: BaseLayoutProps) {
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "32px 16px" }}>
<Section style={{ textAlign: "center", paddingBottom: "24px" }}>
<Img src={brand.logoUrl} width={120} height={36} alt={brand.name} />
</Section>
{/* Main content */}
<Section
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "32px",
}}
>
{children}
</Section>
{/* Footer */}
<Section style={{ paddingTop: "24px", textAlign: "center" }}>
<Text style={{ fontSize: "12px", color: "#6b7280" }}>
© 2026 {brand.name}. All rights reserved.
</Text>
</Section>
</Container>
</Body>
</Html>
);
}Each app defines its brand config and passes it to templates:
export const mainBrand = {
name: "Acme SaaS",
logoUrl: "https://acme.com/logo.png",
primaryColor: "#3b82f6",
fromDomain: "acme.com",
};
export const whitelabelBrand = {
name: "Partner Co",
logoUrl: "https://partner.co/logo.png",
primaryColor: "#10b981",
fromDomain: "partner.co",
};import { WelcomeEmail } from "@acme/emails";
import { mainBrand } from "@/lib/brand";
import { render } from "@react-email/render";
const html = await render(
<WelcomeEmail
brand={mainBrand}
userName="Alice"
dashboardUrl="https://app.acme.com"
/>
);Pattern 3: Template inheritance
Some apps need slight variations of shared templates. Instead of forking the whole file, extend the base template with app-specific wrappers.
import { BaseLayout } from "../components/base-layout";
import { Button } from "../components/button";
import { Text, Section } from "@react-email/components";
export type PasswordResetProps = {
brand: Brand;
resetUrl: string;
expiresInMinutes: number;
};
export function PasswordResetEmail({
brand,
resetUrl,
expiresInMinutes,
}: PasswordResetProps) {
return (
<BaseLayout brand={brand} previewText="Reset your password">
<Text style={{ fontSize: "16px", lineHeight: "24px", margin: "0 0 16px" }}>
Click the button below to reset your password. This link expires in{" "}
{expiresInMinutes} minutes.
</Text>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<Button href={resetUrl} color={brand.primaryColor}>
Reset password
</Button>
</Section>
<Text style={{ fontSize: "14px", color: "#6b7280", margin: "16px 0 0" }}>
If you didn't request this, you can safely ignore this email.
</Text>
</BaseLayout>
);
}The admin app needs an extra warning for password resets. Wrap the shared template instead of duplicating it:
import { PasswordResetEmail, type PasswordResetProps } from "@acme/emails";
import { Section, Text } from "@react-email/components";
export function AdminPasswordResetEmail(props: PasswordResetProps) {
return (
<>
{/* Admin-specific warning */}
<Section
style={{
backgroundColor: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "24px",
}}
>
<Text style={{ margin: 0, fontSize: "14px", color: "#991b1b" }}>
⚠️ This is an admin account reset. Only proceed if you initiated this request.
</Text>
</Section>
{/* Render base template */}
<PasswordResetEmail {...props} />
</>
);
}Pattern 4: Environment-aware rendering
Monorepos often have staging and production environments for each app. Prevent test emails from leaking to production users with environment-aware rendering.
type BaseLayoutProps = {
brand: Brand;
previewText: string;
children: React.ReactNode;
env?: "development" | "staging" | "production";
};
export function BaseLayout({ brand, previewText, children, env = "production" }: BaseLayoutProps) {
// Add environment banner for non-production
const showEnvBanner = env !== "production";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "32px 16px" }}>
{showEnvBanner && (
<Section
style={{
backgroundColor: "#fef3c7",
border: "2px solid #f59e0b",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
textAlign: "center",
}}
>
<Text style={{ margin: 0, fontSize: "14px", fontWeight: "600", color: "#92400e" }}>
🚧 [{env.toUpperCase()}] Test Email — Not sent to real users
</Text>
</Section>
)}
{/* Rest of layout... */}
<Section style={{ textAlign: "center", paddingBottom: "24px" }}>
<Img src={brand.logoUrl} width={120} height={36} alt={brand.name} />
</Section>
<Section style={{ backgroundColor: "#ffffff", borderRadius: "8px", padding: "32px" }}>
{children}
</Section>
<Section style={{ paddingTop: "24px", textAlign: "center" }}>
<Text style={{ fontSize: "12px", color: "#6b7280" }}>
© 2026 {brand.name}. All rights reserved.
</Text>
</Section>
</Container>
</Body>
</Html>
);
}Apps pass the environment from their config:
const html = await render(
<WelcomeEmail
brand={mainBrand}
userName="Alice"
dashboardUrl="https://app.acme.com"
env={process.env.NODE_ENV === "production" ? "production" : "staging"}
/>
);[STAGING] Welcome to Acme makes it impossible to miss test emails.TypeScript advantages
Shared email packages in monorepos work best with strict TypeScript. When you update a template's props, all consuming apps show type errors at build time.
- Add a required prop → TypeScript errors in all apps
- Rename a field → compile-time catch across workspaces
- Export types → consuming apps get autocomplete
- One change, one place, type-safe everywhere
- Update one template, forget the others
- Runtime errors in staging when prop is missing
- No autocomplete, copy-paste prop names
- Hope you remembered to update all copies
// Update template in shared package
export type WelcomeProps = {
brand: Brand;
userName: string;
dashboardUrl: string;
activationStatus: "pending" | "active"; // ← New required prop
};
// All apps now show TypeScript errors until they provide activationStatus
// No runtime surprises, no forgotten copiesBonus: Shared preview server
Run a single preview server for all templates in the monorepo. Designers and PMs can review every email variant without digging through app codebases.
import { PasswordResetEmail, WelcomeEmail, InvoiceEmail } from "../src";
import { mainBrand, whitelabelBrand } from "./brands";
// Simple Next.js app or static page that renders all templates
export default function PreviewGallery() {
return (
<div>
<h1>Email Template Gallery</h1>
<section>
<h2>Password Reset (Main Brand)</h2>
<iframe srcDoc={renderToHtml(<PasswordResetEmail brand={mainBrand} resetUrl="..." expiresInMinutes={30} />)} />
</section>
<section>
<h2>Password Reset (Whitelabel Brand)</h2>
<iframe srcDoc={renderToHtml(<PasswordResetEmail brand={whitelabelBrand} resetUrl="..." expiresInMinutes={30} />)} />
</section>
{/* More templates... */}
</div>
);
}Migrating existing templates
If you already have duplicated templates, migrate incrementally:
- Step 1: Create the
packages/emailsworkspace withpackage.jsonandtsconfig.json - Step 2: Move one template (e.g., password reset) to the shared package and export it
- Step 3: Update one app to import the shared template and verify it works
- Step 4: Migrate other apps one at a time
- Step 5: Delete duplicated templates from app folders
- Step 6: Repeat for remaining templates
Common mistakes
Avoid these pitfalls when setting up shared email packages:
- Not exporting types: Apps need
WelcomePropstypes for autocomplete and validation - Hardcoding URLs: Pass URLs as props instead of embedding
https://app.acme.comin templates - Mixing business logic: Templates should only render UI; keep data fetching and sending logic in apps
- Skipping preview data: Export realistic preview props for local development and testing
- No versioning strategy: Use semantic versioning or workspace protocol versions to manage breaking changes
Recommended
SaaS Essentials Pack
21+ Templates · 60+ Variations. One-time purchase, lifetime updates.
Recommended
SaaS Essentials Pack
21+ Templates · 60+ Variations. One-time purchase, lifetime updates.
Recommended
SaaS Essentials Pack
21+ Templates · 60+ Variations. One-time purchase, lifetime updates.