Ember Template-Only Components: When Dropping the JavaScript Class Is the Right Call
Template-only components in Ember Octane let a single .hbs file serve as a complete component — no empty class, no boilerplate. Here's how they work, a worked badge example, and where to draw the line.
04 May 2026, 13:02 UTC

Your design system folder is full of components like badge, avatar, and empty-state — each with a JavaScript class that does nothing except exist. That empty class is boilerplate you maintain, and a signal to teammates that logic might live there someday. Ember's template-only components remove both problems: the component is just a .hbs file, and the absence of a class becomes a deliberate statement that this piece of UI is presentation only.
This post explains how template-only components work in Ember Octane, shows a concrete example, and draws the line where you should stop and reach for a real class instead.
What a template-only component actually is
Since Ember Octane (Ember CLI 3.13+), a component can consist of a single Handlebars template under app/components/ with no sibling JavaScript file. At build time Ember recognizes the template as a component and backs it with an empty class at runtime. You invoke it exactly like any other component:
{{!-- app/templates/dashboard.hbs --}}
<StatusBadge @label="Active" @tone="success" />There is no registration step and no configuration flag. If the file exists at app/components/status-badge.hbs and there is no status-badge.js next to it, it is template-only. Both angle-bracket invocation (<StatusBadge />) and classic curly invocation ({{status-badge}}) work.
A worked example: a status badge
Say you want a badge that renders a colored dot and a label, with the color chosen by a @tone argument. The entire component is one file:
{{!-- app/components/status-badge.hbs --}}
<span class="badge badge--{{@tone}}">
<span class="badge__dot" aria-hidden="true"></span>
<span class="badge__label">{{@label}}</span>
</span>To create it with the generator instead of by hand, run this in your project root (no special permissions needed beyond write access to the repo):
ember generate component status-badge --template-onlyThe check that matters: confirm the generator created app/components/status-badge.hbs and did not create app/components/status-badge.js. Then start the dev server with ember serve, place <StatusBadge @label="Active" @tone="success" /> in a template, and verify the markup appears in the DOM with the expected class (badge badge--success) and no console warnings.
Note that arguments are read-only in a template-only component — there is no this.args indirection, you just reference @label directly in the template. That is the whole point: data flows in, markup flows out.
Why this is more than saved keystrokes
Three practical benefits, in order of how much they matter day to day:
- A visible contract. A missing class file tells the next developer there is no hidden state or behavior to hunt for. In a large app, that scanning speed is real value.
- Less boilerplate to review and maintain. No empty
import Component from '@glimmer/component'scaffolding cluttering diffs. - Slightly less work at runtime. Ember doesn't instantiate a custom class for each rendered instance. For a handful of badges this is negligible; for a table rendering hundreds of presentational cells it is a modest but free win.
Template-only components also compose well with yielded content. {{yield}} works fine, so wrappers like panels, cards, and layout shells — arguably the most common presentational components — are natural candidates.
Where the line is
The limitations follow directly from having no class. A template-only component cannot:
- Hold local state (no tracked properties).
- Define actions or computed values.
- Use lifecycle hooks or modifiers that require a backing class for DOM setup and teardown.
Interactivity has to live in a parent component, a service, or an element modifier applied from outside. That is fine when the component is genuinely presentational — a badge doesn't need to know it was clicked; the parent does. The failure mode is subtler: when a component almost needs logic, teams start pushing conditionals and helper chains into the template to avoid "promoting" it to a class component. A template with five nested {{#if}} blocks and a {{concat}} pipeline is harder to test and debug than a ten-line Glimmer class would be.
A reasonable rule of thumb: if you find yourself wanting a derived value that isn't a simple argument passthrough, or reaching for a helper to simulate an action, convert the component. Conversion is cheap — add the sibling .js file with a Glimmer component class and move the logic there; the template and all call sites stay unchanged.
Verifying it works
Beyond the smoke test in the browser, run the suite:
ember testA rendering (integration) test for the badge confirms the component compiles and renders in test context, not just in the app:
// tests/integration/components/status-badge-test.js
// (scaffolded by ember generate)
await render(hbs`<StatusBadge @label="Active" @tone="success" />`);
assert.dom('.badge--success .badge__label').hasText('Active');This assumes an Ember CLI project on 3.13 or later with the default JavaScript/Handlebars stack. If you are on an older version, check ember-cli in package.json before relying on the feature.
Closing
Template-only components are not a performance trick; they are a communication tool. Every presentational component you strip down to a single .hbs file makes your component directory easier to read and nudges logic toward the places it can be tested. Audit your app/components/ folder this week: any component whose class file is empty or near-empty is a candidate. Delete the class, run ember test, and enjoy a slightly more honest codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.