← Back to all posts
8 min readCentrali Team

Handlebars Templating in Compute Functions: Generate Dynamic Content Without the Hassle

We added Handlebars templating to compute functions so you can generate emails, receipts, and notifications with a single line of code. Here's why we built it and how to use it.

FeatureComputeTutorialEmail

When you're building an application, you inevitably need to generate dynamic content. Order confirmations. Password reset emails. Invoice PDFs. Webhook payloads. The list goes on.

Most developers end up doing one of two things:

  1. String concatenation - Messy, error-prone, and impossible to maintain
  2. Spin up a templating service - Now you have another service to deploy, monitor, and pay for

Neither option feels good. So we added a third: Handlebars templating, built directly into Centrali compute functions.

What we shipped

The new api.renderTemplate() method lets you render Handlebars templates with your data in a single function call. No external services, no string concatenation, no setup.

typescript
// In your compute function const html = api.renderTemplate( 'Hello {{customer.name}}, your order #{{order.id}} ships on {{formatDate order.shipDate "date"}}.', { customer: { name: 'Sarah Chen' }, order: { id: 'ORD-2024-1847', shipDate: '2025-12-01' } } ); // Output: "Hello Sarah Chen, your order #ORD-2024-1847 ships on December 1, 2025."

That's it. One line of code, and your dynamic content is ready.

Why Handlebars?

We considered several templating engines before settling on Handlebars:

Mustache is simple but too limited—no helpers, no conditionals beyond truthy/falsy checks.

EJS and Pug are powerful but require escaping user content carefully to avoid XSS. One mistake and you've introduced a security vulnerability.

Handlebars hits the sweet spot:

  • Logic-less by design (keeps templates clean)
  • Built-in HTML escaping (secure by default)
  • Extensible through helpers (powerful when you need it)
  • Battle-tested in production at scale

Built-in helpers that actually matter

We didn't just add Handlebars—we added 30+ helpers that solve real problems. Here are the ones you'll use most:

Currency formatting

handlebars
Your total: {{formatCurrency order.total "NGN"}}

Output: Your total: ₦125,000

We support 20+ currency symbols out of the box: USD, EUR, GBP, NGN, JPY, CNY, INR, KES, GHS, ZAR, and more. Unknown currencies fall back gracefully to the currency code.

Date formatting

handlebars
Order placed: {{formatDate order.createdAt "date"}} Delivery window: {{formatDate order.deliveryTime "datetime"}}

Output:

Order placed: November 27, 2025
Delivery window: November 30, 2025, 2:30 PM

Format options: short, long, date, time, datetime, iso

Conditionals that work

Standard Handlebars gives you #if blocks, but comparing values requires custom helpers. We included them:

handlebars
{{#if (eq order.status "shipped")}} Your order is on its way! {{else if (eq order.status "processing")}} We're preparing your order. {{else}} Your order is confirmed. {{/if}} {{#if (gt order.total 10000)}} You qualify for free shipping! {{/if}}

Available comparisons: eq, ne, gt, gte, lt, lte, and, or, not

Working with arrays

handlebars
<h2>Order Summary ({{length items}} items)</h2> <ul> {{#each items}} <li> {{this.name}} x{{this.quantity}} - {{formatCurrency this.total "USD"}} {{#if @first}}(First item!){{/if}} </li> {{/each}} </ul>

Array helpers: length, first, last, join

Math when you need it

handlebars
Subtotal: {{formatCurrency subtotal "USD"}} Tax ({{multiply taxRate 100}}%): {{formatCurrency tax "USD"}} Total: {{formatCurrency (add subtotal tax) "USD"}}

Math helpers: add, subtract, multiply, divide, modulo, round

Real example: Order confirmation email

Here's what a complete order confirmation template looks like:

handlebars
<!DOCTYPE html> <html> <body style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;"> <h1>Thanks for your order, {{customer.firstName}}!</h1> <p>We've received order <strong>#{{order.orderNumber}}</strong> and will start preparing it shortly.</p> <h2>Order Summary</h2> <table style="width: 100%; border-collapse: collapse;"> <tr style="border-bottom: 1px solid #eee;"> <th style="text-align: left; padding: 8px 0;">Item</th> <th style="text-align: right; padding: 8px 0;">Price</th> </tr> {{#each items}} <tr style="border-bottom: 1px solid #eee;"> <td style="padding: 12px 0;"> {{this.name}} {{#if this.variant}} ({{this.variant}}){{/if}} <span style="color: #666;"> × {{this.quantity}}</span> </td> <td style="text-align: right; padding: 12px 0;"> {{formatCurrency this.total "USD"}} </td> </tr> {{/each}} </table> <div style="margin-top: 16px; text-align: right;"> {{#if order.discount}} <p>Discount: <strong>-{{formatCurrency order.discount "USD"}}</strong></p> {{/if}} {{#if (gt order.shipping 0)}} <p>Shipping: {{formatCurrency order.shipping "USD"}}</p> {{else}} <p style="color: #16a34a;">Free Shipping!</p> {{/if}} <p style="font-size: 1.25em;"> <strong>Total: {{formatCurrency order.total "USD"}}</strong> </p> </div> {{#if tracking}} <div style="margin-top: 24px; padding: 16px; background: #f9fafb; border-radius: 8px;"> <h3 style="margin: 0 0 8px 0;">Tracking Information</h3> <p style="margin: 0;"> {{tracking.carrier}}: <strong>{{tracking.number}}</strong> </p> </div> {{/if}} <p style="margin-top: 24px; color: #666; font-size: 0.875em;"> Order placed on {{formatDate order.createdAt "long"}} </p> </body> </html>

Call it from your compute function:

typescript
export default async function sendOrderConfirmation({ event, api }) { const order = event.record.data; // Fetch related data const customer = await api.fetchRecord(order.customerId); const items = await api.queryRecords('order_items', { filter: { orderId: order.id } }); // Render the email const html = api.renderTemplate(ORDER_CONFIRMATION_TEMPLATE, { customer: customer.data, order: order, items: items.items.map(i => i.data), tracking: order.trackingNumber ? { carrier: order.carrier, number: order.trackingNumber } : null }); // Send it (see the Resend section below!) await api.httpPost('https://api.resend.com/emails', { from: 'orders@yourstore.com', to: customer.data.email, subject: `Order #${order.orderNumber} confirmed`, html: html }, { headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}` } }); return { sent: true }; }

Sending emails: Try Resend

Now that you can generate beautiful HTML emails, you need a way to send them. We recommend Resend.

Why Resend?

  • Free tier: 3,000 emails/month, 100 emails/day—plenty for testing and small projects
  • Developer-first API: Send an email with one HTTP request
  • Great deliverability: Built by the team behind React Email
  • Generous pricing: $20/month gets you 50,000 emails

Here's how simple it is to integrate with Centrali:

typescript
// Add api.resend.com to your workspace's allowed domains in Settings const response = await api.httpPost( 'https://api.resend.com/emails', { from: 'Your App <hello@yourapp.com>', to: customer.email, subject: 'Your order is confirmed', html: renderedHtml }, { headers: { Authorization: 'Bearer re_your_api_key_here', 'Content-Type': 'application/json' } } );

That's it. Combine api.renderTemplate() with Resend's API, and you have production-ready transactional emails in under 50 lines of code.

Sign up at resend.com to get your API key.

Security by default

Templates are secure out of the box:

HTML escaping: All variables are escaped by default. {{userInput}} becomes safe HTML.

typescript
api.renderTemplate( 'Hello {{name}}', { name: '<script>alert("xss")</script>' } ); // Output: "Hello &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"

Triple braces for trusted content: Use {{{html}}} only when you explicitly trust the content.

Strict mode: Enable strict mode to catch missing variables during development:

typescript
api.renderTemplate(template, data, { strict: true }); // Throws if any variable in the template is missing from data

Custom helpers and partials

Need functionality we didn't include? Register your own:

typescript
const html = api.renderTemplate( 'Price: {{formatPrice amount}}', { amount: 4999 }, { helpers: { formatPrice: (cents) => `$${(cents / 100).toFixed(2)}` } } ); // Output: "Price: $49.99"

Partials let you reuse template fragments:

typescript
const html = api.renderTemplate( '{{> header}} Main content {{> footer}}', { companyName: 'Acme Inc' }, { partials: { header: '<header>{{companyName}}</header>', footer: '<footer>© 2025 {{companyName}}</footer>' } } );

What you can build

This isn't just for emails. Use api.renderTemplate() for:

  • Invoices and receipts - Generate HTML, convert to PDF with a service like PDFShift
  • Webhook payloads - Dynamically construct JSON or XML payloads for third-party integrations
  • Slack/Discord messages - Build rich notifications with conditional sections
  • SMS templates - Generate personalized text messages
  • Export templates - Create custom CSV or report formats

Get started

The api.renderTemplate() method is available now in all Centrali compute functions. No configuration needed—just start using it.

Check the compute functions documentation for the complete helper reference and more examples.

Building something cool with templating? We'd love to hear about it—drop us a line at feedback@centrali.io.

Building something with Centrali and want to share feedback about this feature?

Email feedback@centrali.io