| name | ember-polaris-migration |
| description | Migrating Ember Octane apps toward Polaris — single-file components (.gjs/.gts), the <template> tag, strict-mode templates, route templates as components, and the mental model shifts. Use when introducing template imports to a codebase, when starting a new Polaris-first project, or when you see <template> blocks and need to understand them. |
| type | reference |
Ember Polaris Migration
Polaris is the next Ember edition. It's an additive set of changes on top of Octane — you don't unlearn Octane to adopt Polaris. The changes are mostly about how templates are authored and how names resolve.
The big shifts:
- Single-file components with
.gjs / .gts and a <template> block.
- Strict-mode templates — names come from JS imports, not the resolver.
- Route templates as components (RFC #1099) — eliminates controllers as the data home for route URLs.
- First-class JS in templates — no helper wrappers around plain functions.
Polaris depends on Embroider (@embroider/core etc.). Classic builds can't run it.
What .gjs / .gts look like
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import FormatDate from 'my-app/helpers/format-date';
import LikeButton from 'my-app/components/like-button';
import type PostModel from 'my-app/models/post';
export interface PostCardSignature {
Args: { post: PostModel };
Element: HTMLElement;
}
export default class PostCard extends Component<PostCardSignature> {
share = () => navigator.share?.({ title: this.args.post.title });
<template>
<article ...attributes>
<h2>{{@post.title}}</h2>
<time>{{FormatDate @post.publishedAt format="long"}}</time>
<LikeButton @postId={{@post.id}} />
<button type="button" {{on "click" this.share}}>
Share
</button>
</article>
</template>
}
Notes:
<template> is a magic block — not a real DOM element. It compiles to the component's template.
- Things used in the template (
on, LikeButton, FormatDate) must be imported.
- Built-ins like
if, each, let are still globally available.
Strict mode = imported scope
In classic mode, <UserCard> is resolved by the file system: app/components/user-card.{ts,hbs}. In strict mode, every name in a template must be in scope — either:
- A class field (
this.share).
- An arg (
@post).
- An import (
LikeButton, FormatDate).
- A built-in keyword (
if, each, let, unless, with, each-in, yield, outlet, mount, component, helper, modifier, array, hash, concat, debugger, log).
This kills "where is this component defined?" mysteries. Click-through-to-definition works.
Template-only components
import type { TOC } from '@ember/component/template-only';
export interface BadgeSignature {
Args: { label: string };
Element: HTMLSpanElement;
}
const Badge: TOC<BadgeSignature> = <template>
<span class="badge" ...attributes>{{@label}}</span>
</template>;
export default Badge;
TOC<T> typing tells Glint to check the template against the signature.
Helpers and modifiers as plain functions
Helpers are just functions you import:
export default function formatDate(date: Date, options: Intl.DateTimeFormatOptions = {}) {
return new Intl.DateTimeFormat('en-US', options).format(date);
}
import FormatDate from 'my-app/helpers/format-date';
<template>{{FormatDate @publishedAt month="long"}}</template>
No helper(...) wrapper, no class extends Helper. If the helper needs DI, you can still extend Helper, but most helpers don't.
Modifiers can also be plain functions via ember-modifier:
import { modifier } from 'ember-modifier';
const autoFocus = modifier((el: HTMLElement) => el.focus());
export default autoFocus;
import autoFocus from 'my-app/modifiers/auto-focus';
<template>
<input {{autoFocus}} />
</template>
Route templates as components (RFC #1099)
Currently, app/templates/posts/show.hbs is a special template. Polaris's route-as-component proposal makes it a regular component you export from the route file:
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostsShowRoute extends Route<Promise<Post>> {
@service declare store: Store;
async model({ post_id }: { post_id: string }) {
return this.store.findRecord('post', post_id);
}
<template>
<article>
<h1>{{@model.title}}</h1>
<p>{{@model.body}}</p>
{{outlet}}
</article>
</template>
}
Status: this RFC is in flight — check the latest Ember release notes. Until it lands, keep templates separate at app/templates/... and lift complex logic into components.
Migration: side-by-side, file by file
You don't migrate everything at once. The two modes coexist:
- Bootstrap Embroider if you haven't already (
@embroider/core + @embroider/compat + @embroider/webpack).
- Install template-imports support:
ember install @glimmer/component
ember install ember-template-imports
ember install ember-modifier
- Add the Glint environment:
"glint": { "environment": ["ember-loose", "ember-template-imports"] }
- New components: write in
.gts. Don't touch existing .hbs/.ts pairs.
- Migrate old components opportunistically when you'd touch them anyway.
A reasonable migration sequence per component:
- Rename
foo.ts + foo.hbs → foo.gts.
- Add
<template>...</template> containing the old .hbs body.
- Find every helper/component/modifier referenced — add an
import for each.
- Run
glint. Fix the resulting errors.
- Delete the old
.hbs.
Mental model shifts
| Before (Octane) | After (Polaris) |
|---|
| File path = component name | Imports = component name |
Helpers wrapped with helper(...) | Helpers are plain functions |
register-helper, classic helper class | Most helpers don't need a class |
Implicit globals ({{format-date}}) | Explicit imports |
| Template registry maintenance | Glint sees imports directly |
app/templates/foo.hbs for routes | (Future) component-shaped routes |
Two files (.ts + .hbs) | One file (.gts) |
What does NOT change in Polaris
@tracked, @action, @service, @cached — same.
- DI / owner / registry — same.
- Router, route hooks, model — same (until RFC #1099 lands).
- Ember Data — same.
- Testing (
@ember/test-helpers, QUnit) — same; tests can render <template>-style components directly.
Tips and traps
Common questions
Q: Can I mix .hbs + .gts in the same app?
Yes. That's the supported migration path. Use ember-template-imports together with the classic resolver.
Q: Do I have to use Embroider?
For .gjs/.gts, yes. For mixed apps, @embroider/compat + classic ember-cli works during the transition.
Q: Does <template> show up in the DOM?
No. It compiles away. The first real element in your template is the root element.
Q: How does ...attributes interact with template-only components?
Same as before — declare an Element in the signature, place ...attributes on the root element.
Verification when introducing Polaris
See also