Cleaner Iteration in Handlebars with a Custom eachWithIndex Helper
Learn how to create a Handlebars block helper that adds an index to each iteration without polluting your data model.
13 Jul 2025, 10:32 UTC

The problem: noisy data for simple list indices
When rendering a list in Handlebars you often need the current position – for zebra striping, showing a counter, or generating unique IDs. The usual workaround is to add an index field to each item in the data you pass to the template, which clutters your model objects with presentation‑only information.
Thesis: move the index logic into a block helper
A Handlebars block helper receives an options.fn function that renders the wrapped block. By invoking that function once per array element and supplying a custom data.index value, the helper can expose the iteration position without touching the underlying data. This keeps model objects clean and lets the template stay declarative.
Implementing eachWithIndex
// eachWithIndex.js
function eachWithIndex(context, options) {
if (!Array.isArray(context)) {
return options.inverse(this);
}
let out = '';
context.forEach((item, i) => {
// Clone the data object to avoid leaking state between iterations
const data = Handlebars.createFrame(options.data || {});
data.index = i;
out += options.fn(item, { data });
});
return out;
}
module.exports = eachWithIndex;
The helper first checks that the supplied context is an array; if not it renders the inverse block (useful for empty‑state handling). For each element it creates a fresh data frame, sets index, and calls options.fn with the item and that data. Because a new frame is made per iteration, there is no accidental state leakage.
Worked example: rendering a blog post list
Assume a template file posts.hbs:
{{#eachWithIndex posts}}
{{this.title}}
Post {{add @index 1}} of {{@length}}
{{this.excerpt}}
{{/eachWithIndex}}
The template uses the built‑in @index variable supplied by our helper, and also shows how you can combine it with other helpers (eq, add) for UI logic.
To use it:
- Install Handlebars runtime:
npm i handlebars - Save the helper implementation as
eachWithIndex.js. - Pre‑compile the template (optional but recommended for production):
handlebars posts.hbs -f posts.js
Then in Node:
const Handlebars = require('handlebars/dist/handlebars.runtime');
const eachWithIndex = require('./eachWithIndex');
const template = require('./posts.js'); // generated by the CLI
Handlebars.registerHelper('eachWithIndex', eachWithIndex);
const context = {
posts: [
{ title: 'First post', excerpt: 'Intro…' },
{ title: 'Second post', excerpt: 'More…' },
{ title: 'Third post', excerpt: '…' }
]
};
const html = template(context);
console.log(html);
Expected output (trimmed for clarity):
<article class="featured">
<h2>First post</h2>
<p>Post 1 of 3</p>
<div>Intro…</div>
</article>
<article>
<h2>Second post</h2>
<p>Post 2 of 3</p>
<div>More…</div>
</article>
<article>
<h2>Third post</h2>
<p>Post 3 of 3</p>
<div>…</div>
</article>
Trade‑off and limitation
The helper adds a small runtime cost: for each iteration it creates a new data frame and invokes options.fn. In most applications this overhead is negligible compared to the benefit of keeping data models free of UI fields. However, if you render extremely large lists (hundreds of thousands of items) you may notice a slowdown; in that case consider generating the index on the server side or using a CSS‑only solution like nth‑child for striping.
Actionable closing
Start by copying the eachWithIndex function into a utilities module, register it once in your Handlebars environment, and replace any manual index fields in your view data with the helper. Verify the output matches the example above, and run a quick performance check with your typical list size. You’ll get cleaner templates, clearer separation of concerns, and no need to mutate your model objects just to show a counter.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.