Code Tips14 min read

React Email in Monorepos: Shared Templates Across Multiple Apps

Stop duplicating email templates across workspaces. Build a shared email package with brand variants, template inheritance, and environment-aware rendering for monorepo codebases using npm/pnpm/Yarn workspaces or Turborepo.

R

React Emails Pro

March 5, 2026

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.

Directory structure
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.json

The packages/emails workspace is a standalone package that exports templates and components. Each app imports only what it needs.

Setting up the email package

packages/emails/package.json
{
  "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:

packages/emails/src/index.ts
// 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:

apps/web/app/api/auth/reset/route.ts
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 });
}
Tip: Add the email package to your app's 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.

packages/emails/src/components/base-layout.tsx
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:

apps/web/lib/brand.ts
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",
};
apps/web/app/api/send-welcome/route.ts
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"
  />
);
Why this works: One set of templates supports multiple brands without duplication. Brand config lives in apps, not in the shared package.

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.

packages/emails/src/templates/password-reset.tsx
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&apos;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:

apps/admin/emails/admin-password-reset.tsx
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} />
    </>
  );
}
When to use this: When 90% of the template is shared but one app needs a small addition. Avoids forking the entire file.

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.

packages/emails/src/components/base-layout.tsx
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:

apps/web/app/api/send-email/route.ts
const html = await render(
  <WelcomeEmail
    brand={mainBrand}
    userName="Alice"
    dashboardUrl="https://app.acme.com"
    env={process.env.NODE_ENV === "production" ? "production" : "staging"}
  />
);
Subject line tagging: Also prefix subjects in non-production environments: [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.

Shared package
  • 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
Duplicated templates
  • 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
Example: Adding a new prop
// 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 copies

Bonus: 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.

packages/emails/preview/app.tsx
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>
  );
}
Deploy to staging: Host the preview server on a protected staging URL so non-technical stakeholders can review changes before merging.

Migrating existing templates

If you already have duplicated templates, migrate incrementally:

  • Step 1: Create the packages/emails workspace with package.json and tsconfig.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
Pro tip: Start with the most frequently updated template (usually password reset or welcome) to get immediate value from centralization.

Common mistakes

Avoid these pitfalls when setting up shared email packages:

  • Not exporting types: Apps need WelcomeProps types for autocomplete and validation
  • Hardcoding URLs: Pass URLs as props instead of embedding https://app.acme.com in 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.

$19.95$9.95Get it

Recommended

SaaS Essentials Pack

21+ Templates · 60+ Variations. One-time purchase, lifetime updates.

$19.95$9.95Get it

Recommended

SaaS Essentials Pack

21+ Templates · 60+ Variations. One-time purchase, lifetime updates.

$19.95$9.95Get it

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates