React Email13 min read

React Email Attachment Patterns: Files, PDFs, and Dynamic Attachments in Production

Handle email attachments in React Email: static files, dynamic PDFs, user uploads, S3 fetching, encoding gotchas, size limits, and multi-file sends that don't break.

R

React Emails Pro

March 2, 2026

Attachments are where "send an email" turns into "fight with MIME types, encoding nightmares, and memory limits."

Most guides cover the happy path: send a static PDF, celebrate, ship. Reality is messier: dynamic invoices, user-uploaded files, multi-attachment invoices, encoding bugs that only appear in Outlook 2016.

This guide covers the patterns that handle real-world attachment workflows in React Email: static files, dynamic PDFs, user uploads, multi-file sends, encoding gotchas, and provider-specific limits.

The four attachment patterns you'll actually use

Every production attachment workflow falls into one of these four buckets:

  • Static files — PDFs, images, or docs that live in your repo
  • Dynamic PDFs — Generated on the fly (invoices, reports, tickets)
  • User uploads — Files from users (contracts, photos, support attachments)
  • External URLs — Fetch from S3, CDN, or third-party API before sending

Each has different encoding, storage, and error-handling requirements. Let's walk through all four.


Pattern 1: Static files (the easy one)

Static files live in your repo. Think: company PDFs, product guides, compliance documents.

app/api/send-with-static-pdf/route.ts
import { readFileSync } from "fs";
import { join } from "path";
import { Resend } from "resend";
import { WelcomeEmail } from "@/emails/welcome";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: Request) {
  const { email } = await req.json();

  // Read static file from public or emails folder
  const pdfPath = join(process.cwd(), "public", "welcome-guide.pdf");
  const pdfBuffer = readFileSync(pdfPath);

  const { data, error } = await resend.emails.send({
    from: "onboarding@yoursaas.com",
    to: email,
    subject: "Welcome to YourSaaS — Here's Your Quick Start Guide",
    react: WelcomeEmail({ name: "Alex" }),
    attachments: [
      {
        filename: "welcome-guide.pdf",
        content: pdfBuffer,
      },
    ],
  });

  if (error) {
    return Response.json({ error }, { status: 500 });
  }

  return Response.json({ success: true, id: data.id });
}
Use readFileSync for small files (<5MB). For larger files, use createReadStream or lazy-load from S3 to avoid blocking the event loop.

Pattern 2: Dynamic PDFs (invoices, receipts, reports)

Dynamic PDFs are generated per request: invoices with line items, receipts with real data, reports with charts.

Two common approaches: Puppeteer (render HTML to PDF) or PDFKit (code-based PDF generation).

Option 1: Puppeteer (HTML → PDF)

Render your React Email template to HTML, then use Puppeteer to convert it to PDF. Works great if you already have a styled HTML invoice.

lib/generate-invoice-pdf.ts
import puppeteer from "puppeteer";
import { render } from "@react-email/render";
import { InvoiceEmail } from "@/emails/invoice";

export async function generateInvoicePDF(invoiceData: InvoiceProps) {
  // Render React Email to HTML
  const html = await render(InvoiceEmail(invoiceData));

  // Launch headless browser
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  // Load HTML and generate PDF
  await page.setContent(html, { waitUntil: "networkidle0" });
  const pdfBuffer = await page.pdf({
    format: "A4",
    printBackground: true, // Include email background colors
  });

  await browser.close();

  return pdfBuffer;
}

Then attach it:

app/api/send-invoice/route.ts
import { Resend } from "resend";
import { generateInvoicePDF } from "@/lib/generate-invoice-pdf";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: Request) {
  const invoiceData = await req.json();

  // Generate PDF
  const pdfBuffer = await generateInvoicePDF(invoiceData);

  const { data, error } = await resend.emails.send({
    from: "billing@yoursaas.com",
    to: invoiceData.email,
    subject: `Invoice #${invoiceData.invoiceNumber}`,
    react: InvoiceEmail(invoiceData),
    attachments: [
      {
        filename: `invoice-${invoiceData.invoiceNumber}.pdf`,
        content: pdfBuffer,
      },
    ],
  });

  if (error) {
    return Response.json({ error }, { status: 500 });
  }

  return Response.json({ success: true });
}
Puppeteer adds ~300MB to your deployment and can timeout on serverless (Vercel limits function execution to 10s on Hobby, 60s on Pro). For high-volume invoice generation, run Puppeteer in a separate service or pre-generate PDFs in a queue.

Option 2: PDFKit (code-based PDFs)

PDFKit is lighter and faster, but you write PDFs imperatively (no HTML). Good for simple receipts and invoices.

lib/generate-receipt-pdf.ts
import PDFDocument from "pdfkit";

export async function generateReceiptPDF(receiptData: ReceiptProps) {
  return new Promise<Buffer>((resolve, reject) => {
    const doc = new PDFDocument();
    const chunks: Buffer[] = [];

    doc.on("data", (chunk) => chunks.push(chunk));
    doc.on("end", () => resolve(Buffer.concat(chunks)));
    doc.on("error", reject);

    // Build PDF
    doc.fontSize(20).text("Receipt", { align: "center" });
    doc.moveDown();
    doc.fontSize(12).text(`Invoice #: ${receiptData.invoiceNumber}`);
    doc.text(`Amount: ${receiptData.amount}`);
    doc.text(`Date: ${receiptData.date}`);

    doc.end();
  });
}

Pattern 3: User uploads (from request body or storage)

Users attach files via upload forms (support tickets, document submission, contract signatures). Files arrive as multipart/form-data or pre-uploaded to S3.

From form data (direct upload)

app/api/support-ticket/route.ts
export async function POST(req: Request) {
  const formData = await req.formData();
  const email = formData.get("email") as string;
  const message = formData.get("message") as string;
  const file = formData.get("file") as File | null;

  const attachments = [];

  if (file) {
    const buffer = Buffer.from(await file.arrayBuffer());
    attachments.push({
      filename: file.name,
      content: buffer,
    });
  }

  const { data, error } = await resend.emails.send({
    from: "support@yoursaas.com",
    to: "tickets@yoursaas.com",
    replyTo: email,
    subject: "New Support Ticket",
    text: message,
    attachments,
  });

  if (error) {
    return Response.json({ error }, { status: 500 });
  }

  return Response.json({ success: true });
}

From S3 (pre-uploaded files)

More common for production apps: users upload to S3, you store the key, then fetch and attach when sending the email.

lib/fetch-s3-attachment.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function fetchS3Attachment(bucket: string, key: string) {
  const command = new GetObjectCommand({ Bucket: bucket, Key: key });
  const response = await s3.send(command);

  // Stream to buffer
  const chunks: Uint8Array[] = [];
  for await (const chunk of response.Body as any) {
    chunks.push(chunk);
  }

  return Buffer.concat(chunks);
}

Then attach:

app/api/send-contract/route.ts
import { fetchS3Attachment } from "@/lib/fetch-s3-attachment";

export async function POST(req: Request) {
  const { email, s3Key } = await req.json();

  const fileBuffer = await fetchS3Attachment("contracts-bucket", s3Key);

  await resend.emails.send({
    from: "contracts@yoursaas.com",
    to: email,
    subject: "Your Signed Contract",
    text: "Attached is your signed contract.",
    attachments: [
      {
        filename: "signed-contract.pdf",
        content: fileBuffer,
      },
    ],
  });

  return Response.json({ success: true });
}
File size limits: Resend allows 40MB total per email. SendGrid allows 30MB. AWS SES allows 10MB. Check your provider's limits and validate file size before sending.

Pattern 4: External URLs (fetch before sending)

Sometimes attachments live at a URL: generated reports from a third-party API, signed PDFs from DocuSign, images from a CDN.

lib/fetch-url-attachment.ts
export async function fetchURLAttachment(url: string) {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Failed to fetch attachment: ${response.statusText}`);
  }

  const buffer = await response.arrayBuffer();
  return Buffer.from(buffer);
}

Use case: send a report generated by an external analytics platform.

app/api/send-report/route.ts
import { fetchURLAttachment } from "@/lib/fetch-url-attachment";

export async function POST(req: Request) {
  const { email, reportUrl } = await req.json();

  const reportBuffer = await fetchURLAttachment(reportUrl);

  await resend.emails.send({
    from: "reports@yoursaas.com",
    to: email,
    subject: "Your Weekly Analytics Report",
    text: "Attached is your weekly report.",
    attachments: [
      {
        filename: "weekly-report.pdf",
        content: reportBuffer,
      },
    ],
  });

  return Response.json({ success: true });
}

Multi-file attachments (invoices + receipts + statements)

Some workflows need multiple attachments: invoice PDF + payment receipt + tax statement. Just pass an array.

app/api/send-billing-bundle/route.ts
export async function POST(req: Request) {
  const { email } = await req.json();

  const invoice = await generateInvoicePDF({ /* data */ });
  const receipt = await generateReceiptPDF({ /* data */ });
  const statement = readFileSync(join(process.cwd(), "public", "tax-statement.pdf"));

  await resend.emails.send({
    from: "billing@yoursaas.com",
    to: email,
    subject: "Your Monthly Billing Documents",
    text: "Attached: invoice, receipt, and tax statement.",
    attachments: [
      { filename: "invoice.pdf", content: invoice },
      { filename: "receipt.pdf", content: receipt },
      { filename: "tax-statement.pdf", content: statement },
    ],
  });

  return Response.json({ success: true });
}
Keep total attachment size under 10MB for best deliverability. Large attachments trigger spam filters and slow delivery. For files >10MB, upload to S3 and send a download link instead.

Encoding gotchas (and how to avoid them)

Attachments break in weird ways: base64 corruption, encoding mismatches, MIME type confusion. Here's what actually causes problems.

1. Wrong MIME type

If you don't specify contentType, providers guess based on filename. This breaks for non-standard extensions.

correct-mime-type.ts
attachments: [
  {
    filename: "contract.pdf",
    content: pdfBuffer,
    contentType: "application/pdf", // Explicit MIME type
  },
  {
    filename: "invoice.xlsx",
    content: xlsxBuffer,
    contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  },
]

2. Encoding corruption (base64 vs Buffer)

Some providers expect Buffer, others expect base64 strings. Resend and SendGrid both accept Buffer. If you need base64:

base64-encoding.ts
const base64Content = pdfBuffer.toString("base64");

attachments: [
  {
    filename: "invoice.pdf",
    content: base64Content,
    encoding: "base64",
  },
]

3. Memory limits (serverless)

Vercel functions have a 4.5MB payload limit (50MB on Pro). If your attachment workflow regularly exceeds this, move attachment generation to a queue or separate service.

Don't generate 20MB PDFs in an API route. Use a background job with BullMQ or trigger a separate Lambda/Cloud Run function.

Testing attachments (before you ship)

Attachment bugs are silent: the email sends, but the PDF is corrupted or missing. Here's how to catch them.

  • Unit test the generator: Ensure PDF generation returns a valid Buffer (check buffer.length > 0).
  • Visual inspection: Send to yourself, download the attachment, open it.
  • Cross-client test: Gmail, Outlook, and Apple Mail handle attachments slightly differently. Test all three.
  • Monitor logs: Track attachment size and encoding errors in production (log buffer.length before sending).

Production checklist

Before shipping attachment workflows to production:

  • ✅ Validate file size before generating/fetching (reject files >10MB)
  • ✅ Set explicit contentType for all attachments
  • ✅ Handle errors gracefully (PDF generation can fail, S3 fetch can timeout)
  • ✅ Test in Gmail, Outlook, and Apple Mail (different rendering engines)
  • ✅ Log attachment metadata (filename, size, encoding) for debugging
  • ✅ Move large PDF generation to a background queue (don't block API routes)
  • ✅ Sanitize user-uploaded filenames (remove spaces, special characters)
For production-ready email templates with attachment-friendly layouts, see our invoice template and transactional template bundle.

Attachments aren't hard. They're just unforgiving. Get the encoding right, respect size limits, and test across clients. Everything else is just glue code.

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