L'email transactionnel est l'un des points de contact les plus négligés d'un produit web. On bricole un template HTML inline, on bourrine du nodemailer avec un SMTP Gmail, et trois mois plus tard les confirmations de commande finissent en spam. Pour un site vitrine, un SaaS ou un e-commerce, c'est inacceptable : un email qui n'arrive pas, c'est un client perdu.
Depuis dix-huit mois, j'ai adopté la combinaison Resend (envoi) + React Email (templating) sur la quasi-totalité de mes projets Next.js et NestJS. C'est devenu mon défaut, et je vais vous expliquer pourquoi — et comment l'intégrer proprement.
Pourquoi Resend plutôt que SendGrid, Mailgun ou Nodemailer
Avant de plonger dans le code, mettons les choses à plat. Les solutions historiques pour envoyer un email depuis une app Node sont :
- Nodemailer + SMTP : gratuit, mais déliverabilité catastrophique sans serveur dédié, gestion DKIM/SPF/DMARC manuelle, pas de tracking.
- SendGrid / Mailgun / AWS SES : robustes mais SDKs vieillots, dashboards datés, courbe d'apprentissage rude, et la facturation Twilio (SendGrid) a explosé en 2024.
- Postmark : excellent pour la déliverabilité, mais coûteux et API moins agréable.
Resend, fondé par l'équipe de React Email, est arrivé avec une promesse simple : une API REST claire, un SDK TypeScript first-class, et l'intégration native avec des templates React. Concrètement :
- 3 000 emails/mois gratuits (largement suffisant pour un site vitrine ou un SaaS débutant).
- Déliverabilité au niveau de Postmark (même infrastructure derrière).
- DKIM/SPF/DMARC configurés via l'interface en pointant trois enregistrements DNS.
- SDK officiel
resendqui prend nativement un composant React en paramètre.
Pour un freelance qui livre des projets clients, c'est un game changer : je peux setup la déliverabilité d'un nouveau domaine en moins de 15 minutes, et le client reçoit un dashboard où il peut suivre ses emails sans dépendre de moi.
Installation et configuration
Côté Resend, on crée un compte, on ajoute son domaine (exemple.com), on copie les enregistrements DNS chez son registrar (OVH, Gandi, Cloudflare...), on attend la vérification, et on génère une API key.
Côté code, on installe les deux paquets :
npm install resend @react-email/components
@react-email/components est la lib qui fournit les composants React optimisés pour les clients mail (Outlook, Gmail, Apple Mail). Elle compile en HTML inline avec les tables et styles inline que ces clients exigent encore en 2026.
Créer un template avec React Email
Le gros intérêt de React Email, c'est de pouvoir écrire ses templates avec la syntaxe JSX qu'on connaît déjà, avec de l'auto-completion TypeScript et de la composition de composants. Voici un template de confirmation de commande :
// emails/order-confirmation.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
interface OrderConfirmationProps {
customerName: string;
orderNumber: string;
totalAmount: number;
items: { name: string; quantity: number; price: number }[];
}
export default function OrderConfirmation({
customerName,
orderNumber,
totalAmount,
items,
}: OrderConfirmationProps) {
return (
<Html>
<Head />
<Preview>Votre commande {orderNumber} est confirmée</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{ padding: "20px", maxWidth: "600px" }}>
<Heading>Merci {customerName} !</Heading>
<Text>Votre commande #{orderNumber} a bien été enregistrée.</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item) => (
<Text key={item.name}>
{item.quantity} × {item.name} — {item.price.toFixed(2)} €
</Text>
))}
</Section>
<Text style={{ fontWeight: "bold", marginTop: "16px" }}>
Total : {totalAmount.toFixed(2)} €
</Text>
<Button
href="https://exemple.com/commandes"
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
}}
>
Voir ma commande
</Button>
</Container>
</Body>
</Html>
);
}
Ce qui est puissant ici, c'est qu'on peut prévisualiser le rendu en dev avec la commande npx react-email dev, ce qui ouvre un serveur local avec un viewer qui simule différents clients. Fini les boucles "envoyer → ouvrir Outlook → constater que c'est cassé".
Intégration côté Next.js : Server Action
Dans une app Next.js 16 avec App Router, le pattern le plus propre est d'envelopper Resend dans une Server Action. On garde l'API key strictement côté serveur, et on évite les API routes inutiles.
// app/actions/send-order-email.ts
"use server";
import { Resend } from "resend";
import OrderConfirmation from "@/emails/order-confirmation";
const resend = new Resend(process.env.RESEND_API_KEY);
interface SendOrderEmailInput {
to: string;
customerName: string;
orderNumber: string;
totalAmount: number;
items: { name: string; quantity: number; price: number }[];
}
export async function sendOrderConfirmation(input: SendOrderEmailInput) {
const { data, error } = await resend.emails.send({
from: "Boutique <commandes@exemple.com>",
to: input.to,
subject: `Confirmation de votre commande #${input.orderNumber}`,
react: OrderConfirmation({
customerName: input.customerName,
orderNumber: input.orderNumber,
totalAmount: input.totalAmount,
items: input.items,
}),
});
if (error) {
console.error("Resend error", error);
throw new Error("Échec de l'envoi de l'email");
}
return data;
}
Le passage du composant React via la clé react est ce qui rend cette stack agréable : Resend gère lui-même le rendu HTML + version texte en fallback. Pas de render() manuel à appeler.
Côté composant client, on appelle ça depuis un useTransition :
"use client";
import { useTransition } from "react";
import { sendOrderConfirmation } from "@/app/actions/send-order-email";
export function ConfirmOrderButton({ order }: { order: Order }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await sendOrderConfirmation({
to: order.customerEmail,
customerName: order.customerName,
orderNumber: order.id,
totalAmount: order.total,
items: order.items,
});
});
}}
>
{isPending ? "Envoi..." : "Confirmer la commande"}
</button>
);
}
Intégration côté NestJS : module dédié
Pour un backend NestJS, je crée systématiquement un MailerModule qui encapsule Resend et expose des méthodes typées par cas d'usage. Ça évite de répandre du resend.emails.send(...) un peu partout.
// src/mailer/mailer.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { Resend } from "resend";
import { MailerService } from "./mailer.service";
@Module({
imports: [ConfigModule],
providers: [
{
provide: "RESEND_CLIENT",
useFactory: (config: ConfigService) =>
new Resend(config.getOrThrow<string>("RESEND_API_KEY")),
inject: [ConfigService],
},
MailerService,
],
exports: [MailerService],
})
export class MailerModule {}
// src/mailer/mailer.service.ts
import { Inject, Injectable, Logger } from "@nestjs/common";
import { Resend } from "resend";
import OrderConfirmation from "../emails/order-confirmation";
@Injectable()
export class MailerService {
private readonly logger = new Logger(MailerService.name);
constructor(@Inject("RESEND_CLIENT") private readonly resend: Resend) {}
async sendOrderConfirmation(params: {
to: string;
customerName: string;
orderNumber: string;
totalAmount: number;
items: { name: string; quantity: number; price: number }[];
}) {
const { data, error } = await this.resend.emails.send({
from: "Boutique <commandes@exemple.com>",
to: params.to,
subject: `Confirmation de votre commande #${params.orderNumber}`,
react: OrderConfirmation({
customerName: params.customerName,
orderNumber: params.orderNumber,
totalAmount: params.totalAmount,
items: params.items,
}),
});
if (error) {
this.logger.error(`Échec d'envoi vers ${params.to}`, error);
throw error;
}
this.logger.log(`Email envoyé : ${data?.id}`);
return data;
}
}
L'avantage du pattern, c'est qu'on peut injecter MailerService partout (resolvers GraphQL, controllers REST, listeners d'events) sans dupliquer la logique. Et le jour où on veut changer de provider, on touche un seul fichier.
Asynchrone et résilient : combiner avec BullMQ
Un email transactionnel ne doit jamais bloquer une requête HTTP. Un utilisateur clique sur "Confirmer ma commande", on enregistre la commande, on lui répond 200 OK, et ensuite on envoie l'email. Sinon, si Resend a un hoquet, l'utilisateur attend 30 secondes et finit par rafraîchir, ce qui crée une commande en double.
Dans une app NestJS sérieuse, je passe systématiquement par une queue BullMQ :
// src/mailer/email.processor.ts
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Job } from "bullmq";
import { MailerService } from "./mailer.service";
@Processor("emails")
export class EmailProcessor extends WorkerHost {
constructor(private readonly mailer: MailerService) {
super();
}
async process(job: Job) {
switch (job.name) {
case "order-confirmation":
return this.mailer.sendOrderConfirmation(job.data);
default:
throw new Error(`Unknown job: ${job.name}`);
}
}
}
// dans le service métier
await this.emailQueue.add("order-confirmation", {
to: customer.email,
customerName: customer.name,
orderNumber: order.id,
totalAmount: order.total,
items: order.items,
}, {
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
removeOnComplete: 100,
});
Avec ce setup, si Resend renvoie un 500 transitoire, BullMQ retente automatiquement 3 fois avec backoff exponentiel. L'API HTTP reste rapide, l'utilisateur n'attend pas, et la commande est confirmée même si l'email part avec 30 secondes de délai.
Bonnes pratiques de déliverabilité
Quelques points qui font la différence entre "mes emails arrivent" et "mes emails finissent dans le dossier promotions" :
- Configurer SPF, DKIM et DMARC. Resend fournit les trois enregistrements DNS dès l'ajout du domaine. Ne sautez jamais cette étape — un email envoyé depuis un domaine non vérifié finit en spam dans 60 % des cas.
- Utiliser un sous-domaine dédié aux transactionnels (
mail.exemple.comoutransactional.exemple.com). Ça isole la réputation : si une campagne marketing pète, vos confirmations de commande continuent de partir. - Toujours inclure un objet précis et un préheader (la prop
Previewde React Email). Les boîtes mail affichent ce texte en aperçu, et un préheader bien rédigé augmente sensiblement le taux d'ouverture. - Inclure un lien de désabonnement même pour les transactionnels (RGPD et Yahoo/Gmail l'exigent depuis 2024 pour les expéditeurs en volume).
- Surveiller les bounces et complaints via les webhooks Resend. Si un email bounce, on retire l'adresse de la base — sinon, la réputation de l'expéditeur s'effondre.
Webhooks Resend pour suivre l'état
Resend expose des webhooks pour email.sent, email.delivered, email.bounced, email.complained. Voici un controller NestJS minimaliste pour les recevoir :
// src/mailer/webhook.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
@Controller("webhooks/resend")
export class ResendWebhookController {
@Post()
async handle(@Body() event: { type: string; data: { email_id: string; to: string[] } }) {
switch (event.type) {
case "email.bounced":
case "email.complained":
// Marquer l'adresse comme invalide en base
break;
case "email.delivered":
// Log ou métrique
break;
}
return { received: true };
}
}
En production, n'oubliez pas de vérifier la signature svix-signature que Resend ajoute aux requêtes — sans ça, n'importe qui peut spammer votre endpoint et flagger vos contacts comme bounced.
Synthèse
La stack Resend + React Email résout des problèmes que la combinaison Nodemailer + Handlebars laissait pourrir depuis des années : templating type-safe avec composition React, déliverabilité au niveau enterprise, DX réellement agréable, et tarification raisonnable pour les petits volumes.
Pour aller plus loin, je vous recommande de lire mon article sur BullMQ et NestJS pour le pattern de queue asynchrone, et celui sur Zod dans Next.js et NestJS pour valider les payloads d'emails avant envoi (un email avec un nom client undefined dans l'objet, ça arrive plus souvent qu'on le pense).
Vous avez un projet web — site vitrine, e-commerce, SaaS — qui nécessite des emails transactionnels fiables ? N'hésitez pas à me contacter pour en discuter, je serai ravi de vous accompagner sur l'architecture et l'intégration.