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/.gtsand 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
// app/components/post-card.gts
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,letare 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
// app/components/badge.gts
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:
// app/helpers/format-date.ts
export default function formatDate(date: Date, options: Intl.DateTimeFormatOptions = {}) {
return new Intl.DateTimeFormat('en-US', options).format(date);
}
// usage in a .gts file
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:
// app/routes/posts/show.gts (proposed Polaris pattern)
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 # most teams already have this - Add the Glint environment:
// tsconfig.json "glint": { "environment": ["ember-loose", "ember-template-imports"] } - New components: write in
.gts. Don't touch existing.hbs/.tspairs. - 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.hbsbody. - Find every helper/component/modifier referenced — add an
importfor 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
type="button"is not optional in templates. The lint rule (require-button-type) is on by default and Polaris-era code keeps it.{{this.x}}vs{{x}}: in strict mode,{{x}}is a name lookup that must resolve to an import or built-in. Always use{{this.x}}for class fields.- Dynamic component invocation still uses
(component ...)and<this.dynamicCmp />. The curried component reference is just a JS value now. - Yielding components: yield the component reference —
{{yield (component LikeButton @postId=@id)}}and the parent calls<x.LikeButton />. hbstemplate literal in tests: in template-imports mode, you can render components directly in tests instead of viahbs\...``:// .gts test file await render(<template><PostCard @post={{this.post}} /></template>);
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
- Embroider is in the build pipeline.
-
ember-template-importsis installed and Glint includes theember-template-importsenvironment. - New components are
.gtswith explicit imports. -
template-lintis upgraded to a version that supports.gjs/.gts. - Tests render via
<template>(where convenient) for cleaner setup. - No
.hbs/.tspairs are being added; all new code is single-file.
See also
- Official: emberjs/rfcs#779 (template tag), emberjs/rfcs#496, emberjs/rfcs#1099 (route templates as components).
- typed-ember/glint — the type-checker.
ember-typescript-and-glint— type-checking templates.ember-components-and-templates— what<template>is replacing.ember-octane-fundamentals— the foundation Polaris builds on.