Loading tutorials…
Loading tutorials…
Dynamic Templates are SendGrid's best-kept feature: template lives in SendGrid, your app sends a small JSON payload, marketing edits the design without touching code. But the Handlebars syntax, versioning, and dynamic_template_data shape have sharp edges that cost an afternoon to learn the hard way.
Who this is forDevelopers who want to stop hardcoding HTML in their app code, OR marketing teams who want to edit transactional email design without engineering tickets. If you have 5+ transactional emails (welcome, password reset, receipt, etc.), Dynamic Templates pays back fast.
What you'll need
Step 1
SendGrid → Email API → Dynamic Templates → Create a Dynamic Template. Add Version. Choose Code Editor or Design Editor.
SendGrid → Email API → Dynamic Templates → Create a Dynamic Template.
Name: descriptive + versioned in your mind — "welcome-email" or "password-reset-v1." The template name is what your code references, so pick clean.
Click Add Version. Choose Code Editor (raw HTML + Handlebars, recommended for engineers) or Design Editor (drag-and-drop, recommended for marketers).
Choose a starting template OR Start from Blank. Blank is cleaner for production templates.
You'll see the editor with HTML + the right-hand Test Data panel. The Test Data is the JSON shape your `dynamic_template_data` will provide at send time.
Save the template. Note the Template ID (`d-xxxxxxxxxxxxxxxx`) — this is what your code references.
Step 2
Use `{{variable}}` for output, `{{#if condition}}...{{/if}}` for branches, `{{#each items}}...{{/each}}` for arrays. Mind the truthy/falsy rules.
Basic variable: `Hello {{first_name}},` — outputs the value of `first_name` from your dynamic_template_data.
Nested: `{{user.email}}` — drills into the user object.
Conditional: `{{#if is_premium}}You're on Premium!{{else}}Upgrade to Premium for ${{premium_price}}.{{/if}}`. Truthy = anything not null/undefined/false/0/empty string/empty array.
Loop: `{{#each cart_items}}<li>{{this.name}} - ${{this.price}}</li>{{/each}}`. `this` refers to current item.
Equality: `{{#if (eq plan "free")}}...{{/if}}` — note the parentheses; SendGrid supports `eq`, `neq`, `gt`, `lt`, `and`, `or`.
Default values: `{{#if first_name}}{{first_name}}{{else}}friend{{/if}}`. Use defensively — never assume the variable exists.
Escaping: `{{variable}}` HTML-escapes by default (prevents XSS). `{{{variable}}}` skips escaping — only use for trusted HTML.
Comments: `{{!-- This is a Handlebars comment, not rendered --}}`.
Step 3
Right-hand panel: paste the JSON shape your app will send. Renders in the preview pane in real time.
In the template editor, the right panel is Test Data — a JSON editor.
Paste a realistic payload: `{ "first_name": "Sarah", "user": { "email": "sarah@example.com" }, "is_premium": false, "cart_items": [{ "name": "T-shirt", "price": "29.00" }, { "name": "Hat", "price": "15.00" }] }`
Click Preview. The right-side pane renders the template with your test data. Iterate until it looks right.
Test edge cases: empty arrays, missing fields, very long strings, special characters in names ("O'Brien", emoji, non-ASCII). The template should degrade gracefully.
Click Test Send. Enter a real email address — receives a rendered version. Open on desktop AND mobile (Gmail iOS, Outlook desktop, Yahoo web) to verify layout.
Save once preview + test send look correct on all clients.
Step 4
POST to /v3/mail/send with `template_id` + `personalizations[0].dynamic_template_data`. Do NOT include subject or content blocks — the template owns both.
Construct the API payload referencing your template ID and dynamic data.
Node: `await sgMail.send({ to: 'sarah@example.com', from: 'hello@em.yourbrand.com', templateId: 'd-xxxxxxxxxxxxxxxx', dynamicTemplateData: { first_name: 'Sarah', is_premium: false, cart_items: [...] } });`
Critical: when sending a Dynamic Template, do NOT include `subject` or `content` in the payload. The template owns both. Including them either errors out or silently overrides the template (depending on SendGrid version).
The `dynamic_template_data` shape must match what the template expects. If your template uses `{{first_name}}` but you send `{ "firstName": "..." }`, the output renders as a blank string.
Subject lines: set the subject in the template editor (top of editor: Subject field with Handlebars support). `{{first_name}}, your order shipped!` works.
Test: send to yourself. Verify the right template renders with the right data. Check that subject + body both use Handlebars substitution.
Step 5
Each template can have multiple versions. Only one is Active. Edit a copy, test, then swap Active.
On the template detail page → Versions tab → see all versions. One is marked Active (used by sends in production).
To make a change: Duplicate the Active version → edit the copy. Doesn't affect production until you mark the new version Active.
Test the new version: click Test Send to your team's test addresses. Verify in your app's staging/preview environment by referencing the new version's ID temporarily.
Promote: when satisfied, mark the new version Active. The old version remains in the Versions list as Inactive — useful for rollback.
Rollback: if something breaks in production, mark the previous version Active again. Instant rollback.
Naming versions: "v1," "v2 - added unsubscribe link," "v3 - mobile fix." Version notes are searchable.
Step 6
Open Tracking inflates by 30-50% from Apple MPP. For transactional templates, consider disabling open tracking per-send.
SendGrid → Settings → Tracking Settings: Open Tracking is account-wide ON by default.
For transactional templates (password reset, receipt) you don't care about open rate — and the tracking pixel makes the email slightly heavier. Disable per-send via mail_settings.
Node: `await sgMail.send({ ..., trackingSettings: { openTracking: { enable: false } } });`
For marketing templates, keep open tracking ON but rely on CLICK rate, not open rate, as your real engagement metric (Apple MPP only inflates opens; clicks are real).
Set per-template tracking: in the template editor → Settings → tracking can be overridden, but per-send override via API is cleaner.
Step 7
Create constants for every template ID. Document the expected dynamic_template_data shape. Version-control both.
In your codebase, create `src/lib/email-templates.ts`:
export const TEMPLATES = { WELCOME: 'd-aaa...', PASSWORD_RESET: 'd-bbb...', RECEIPT: 'd-ccc...', SHIPMENT: 'd-ddd...' } as const;
For each template, document the expected dynamic_template_data shape with TypeScript types: `type WelcomeData = { first_name: string; activation_link: string };`
Wrap sends in typed helper functions: `sendWelcome(to: string, data: WelcomeData)` → builds the API call with the correct template ID and validates the data shape.
When marketing edits a template, code doesn't change. When code needs a new variable, communicate to marketing → add to template → both ship together.
Periodic audit: 1x/quarter, list all live templates, verify each is still in use. SendGrid has no "last used" timestamp — track in your code grep.
Common mistakes
Empty array rendering as "you have items in your cart"
What goes wrong: Handlebars treats `[]` as truthy. Your `{{#if cart_items}}` block fires even when the cart is empty. Customers receive abandoned-cart emails that say 'Items waiting!' with no items rendered below. Looks broken; complaints follow.
How to avoid: Use `{{#if cart_items.length}}` to check for non-empty arrays. Handlebars resolves `.length` and 0 is falsy. Test with empty array data in the Test Data panel before shipping.
Including subject + content in the API payload AND the template
What goes wrong: API-side subject overrides template-side subject silently in some SDK versions. Marketing edits subject in template → no change in production because code overrides. Hours of confusion debugging 'why isn't my template change live?'
How to avoid: When sending a Dynamic Template, only include `template_id` + `dynamic_template_data` + `to`/`from`/`reply_to`. Let the template own subject + content entirely.
Editing the Active version directly in production
What goes wrong: No way to preview before customers see the change. A typo or broken Handlebars syntax ships instantly to the next 100K sends. Rollback is manual (find the previous content, paste it back) and can take 10-30 minutes while bad emails ship.
How to avoid: Always Duplicate → edit the copy → test → mark Active. Old version becomes the instant rollback. SendGrid keeps version history indefinitely.
No default values for optional Handlebars variables
What goes wrong: User without a `first_name` set receives 'Hello ,' — visible whitespace, looks broken, lowers trust. On signup flows where first_name is genuinely optional, this happens often.
How to avoid: Use `{{#if first_name}}{{first_name}}{{else}}friend{{/if}}` defensively. Or set defaults in your code's helper function: `data.first_name = data.first_name || 'friend'`.
Sending raw HTML through `{{{variable}}}` from user input
What goes wrong: XSS risk — if a user's name or any user-controlled field is rendered via triple-stash (`{{{...}}}`), HTML injection is possible. Attackers can inject phishing-style content into your own templates.
How to avoid: Always use `{{variable}}` (double-stash, auto-escapes HTML). Triple-stash `{{{variable}}}` only for trusted system-generated HTML (e.g., rendered markdown from your own backend).
No centralized template ID constants in code
What goes wrong: Template IDs hardcoded as strings in 12 different files. Marketing renames a template → SendGrid changes the ID → developers hunt all 12 references. One missed = customers receive blank emails for a week.
How to avoid: Define `TEMPLATES` constants in one file (`src/lib/email-templates.ts`). Reference everywhere via constant. When IDs change, change in one place.
Recap
Done — what's next
SendGrid Web API vs SMTP Relay — the honest decision and full setup for each
Read the next tutorial
Hand it off
Dynamic Templates pay back fast once you have 5+ transactional emails — but the Handlebars edge cases burn an afternoon every time you hit a new one. A specialist who has built 100+ SendGrid templates will deliver your full library (welcome, receipt, reset, alerts) with typed helpers and tests in 6-8 hours. Typical engagement is $400-700 at $14-16/hr.
See specialist rates
Legacy Templates use SendGrid's old `substitutions` syntax (limited string replacement). Dynamic Templates use Handlebars (full templating with conditionals, loops, helpers). Dynamic Templates are the only ones being maintained in 2026 — Legacy is read-only at this point. All new builds should be Dynamic.
Yes — that's the main reason to use Dynamic Templates. Marketing logs in, edits the template version, marks Active. Code doesn't change. The shape of `dynamic_template_data` is the contract: as long as marketing uses the same variables, design changes don't require deploys.
300 per account on Email API plans (Free through Premier). Versions per template: 300. More than enough for most teams. If you're hitting the limit, you have a templating-pattern problem — consolidate similar templates with conditionals.
Yes — Test Data panel renders with your provided JSON. For production-level testing: use the `mail_settings.sandbox_mode.enable=true` API parameter, which validates the send without actually sending. Useful for CI tests.
Three common causes: (1) you sent via Legacy Template API with `substitutions` instead of Dynamic API with `dynamic_template_data`, (2) the variable name in your payload doesn't match the template (e.g., `firstName` vs `first_name`), (3) the template was created with the old Marketing Campaigns syntax instead of Dynamic Templates.
Two options: (1) Duplicate the version, edit, use Test Send to a test email (doesn't go to real recipients), (2) Reference the new version's ID temporarily in your staging environment's send code — verify, then promote to Active for production.
SendGrid
Web API or SMTP Relay is one of those decisions that looks like '5 minutes of research' and turns into a 2-week migration when you pick wrong. The defaults each tutorial pushes are usually backward for your real use case. Here's the honest tradeoff and complete setup for both.
SendGrid
SendGrid Marketing Campaigns is structurally different from Mailchimp or Klaviyo — it's a marketing layer bolted onto a developer-first sending platform. The UI feels like 'they tried.' For dev teams already on SendGrid for transactional, it's the path of least resistance. For marketing-first teams, Klaviyo or Brevo usually wins. Here's how to make it work.
SendGrid
Event Webhooks are how your app actually learns what happened to a send — delivered, opened, clicked, bounced, complained. The setup is 5 minutes; the production-grade version (signature verification, idempotency, retry handling) is 2 hours. Most teams skip the second part and find out at month 6 when payloads start no-op'ing.
SendGrid
DIY SendGrid is the right call until it isn't. The signal isn't 'sending more emails' — it's that the cost-of-mistakes finally outweighs the cost-of-hiring. Here's the honest framework for when that line is crossed.