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.
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.
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 });
}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.
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:
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 });
}Option 2: PDFKit (code-based PDFs)
PDFKit is lighter and faster, but you write PDFs imperatively (no HTML). Good for simple receipts and invoices.
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)
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.
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:
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 });
}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.
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.
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.
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 });
}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.
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:
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.
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.lengthbefore sending).
Production checklist
Before shipping attachment workflows to production:
- ✅ Validate file size before generating/fetching (reject files >10MB)
- ✅ Set explicit
contentTypefor 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)
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.