Migrating to <template> tag

<template> tag lets us write template(s) in strict mode. Templates are more explicit and statically analyzable, so we can adopt more tools from the JavaScript ecosystem. There’s less work for Embroider to do, so we can expect app builds to be fas…


This content originally appeared on DEV Community and was authored by Isaac Lee

<template> tag lets us write template(s) in strict mode. Templates are more explicit and statically analyzable, so we can adopt more tools from the JavaScript ecosystem. There's less work for Embroider to do, so we can expect app builds to be faster.

<template> tag has been around for a few years now. With the help of addons, in components and tests since 2020 and in routes since 2023. In short, it's stable and you can likely use <template> tag in your projects today.

This post aims to accelerate adoption. I will show you step-by-step how to migrate an existing component, route, and test. Once you understand the idea, feel free to run my codemod to automate the steps.

1. Where did things come from?

To enter strict mode, we will be importing objects (e.g. components, helpers, modifiers) instead of letting string names in templates be somehow resolved. So let's first look at where things come from.

Package Objects
@ember/component Input, Textarea
@ember/helper array, concat, fn, get, hash, uniqueId
@ember/modifier on
@ember/routing LinkTo
@ember/template htmlSafe
@embroider/util ensureSafeComponent

The right column shows objects native to Ember. We name-import them from the path shown in the left. For example,

import { array } from '@ember/helper';

Notice, there's a naming convention:

  • Component names follow Pascal case. (Capitalize the first letter of each word.)
  • All other names follow camel case.

For Ember addons, you can always do a default-import. However, for each object, you have to remember the full path, add 1 line of code, and type many characters. It's also easy to introduce inconsistencies in the import name (the "local" name).

import ContainerQuery from 'ember-container-query/components/container-query';
import height from 'ember-container-query/helpers/height';
import width from 'ember-container-query/helpers/width';

Due to these reasons, always do name-imports when an addon provides a barrel file. If it doesn't, consider making a pull request.

import { ContainerQuery, height, width } from 'ember-container-query';

2. How to migrate

We will consider a component just complex enough to cover the important steps. You can use ember-workshop to test the code shown below. <Hello> is defined in my-addon, then rendered and tested in my-app.

a. Components

<Hello> is a Glimmer component that receives 1 argument. For simplicity, we ignore the stylesheet and component signature.

{{! my-addon: src/components/hello.hbs }}
<div
  class={{local
    this.styles
    "container"
    (if this.someCondition (array "hide" "after-3-sec"))
  }}
>
  {{t "hello.message" name=@name}}
</div>
/* my-addon: src/components/hello.ts */
import Component from '@glimmer/component';

import styles from './hello.css';

export default class Hello extends Component<HelloSignature> {
  styles = styles;

  get someCondition(): boolean {
    return true;
  }
}

1. Change the class' file extension to .gts. Insert an empty <template> tag at the end of the class body.

/* my-addon: src/components/hello.gts */
import Component from '@glimmer/component';

import styles from './hello.css';

export default class Hello extends Component<HelloSignature> {
  styles = styles;

  get someCondition(): boolean {
    return true;
  }
+
+   <template>
+   </template>
}

2. Copy the template from .hbs and paste it into the <template> tag. Delete the .hbs file afterwards.

/* my-addon: src/components/hello.gts */
import Component from '@glimmer/component';

import styles from './hello.css';

export default class Hello extends Component<HelloSignature> {
  styles = styles;

  get someCondition(): boolean {
    return true;
  }

  <template>
+     <div
+       class={{local
+         this.styles
+         "container"
+         (if this.someCondition (array "hide" "after-3-sec"))
+       }}
+     >
+       {{t "hello.message" name=@name}}
+     </div>
  </template>
}

3. Enter strict mode. That is, specify where things come from.

/* my-addon: src/components/hello.gts */
+ import { array } from '@ember/helper';
import Component from '@glimmer/component';
+ import { t } from 'ember-intl';
+ import { local } from 'embroider-css-modules';

import styles from './hello.css';

export default class Hello extends Component<HelloSignature> {
  styles = styles;

  get someCondition(): boolean {
    return true;
  }

  <template>
    <div
      class={{local
        this.styles
        "container"
        (if this.someCondition (array "hide" "after-3-sec"))
      }}
    >
      {{t "hello.message" name=@name}}
    </div>
  </template>
}

4. (Optional) Remove unnecessary code. Examples include:

  • Constants passed to the template via the class
  • Template registry (the declare module block), if you no longer have to support *.hbs files or hbs tags.
  • ensureSafeComponent() from @embroider/util (pass the component directly)
  • One-off helpers
/* my-addon: src/components/hello.gts */
import { array } from '@ember/helper';
import Component from '@glimmer/component';
import { t } from 'ember-intl';
import { local } from 'embroider-css-modules';

import styles from './hello.css';

export default class Hello extends Component<HelloSignature> {
-   styles = styles;
-
  get someCondition(): boolean {
    return true;
  }

  <template>
    <div
      class={{local
-         this.styles
+         styles
        "container"
        (if this.someCondition (array "hide" "after-3-sec"))
      }}
    >
      {{t "hello.message" name=@name}}
    </div>
  </template>
}

Note, we pass the signature of a Glimmer component to the base class Component. For template-only components, we can provide the signature in two ways:

/* my-addon: src/components/hello.gts */
import type { TOC } from '@ember/component/template-only';
import { t } from 'ember-intl';

import styles from './hello.css';

const Hello: TOC<HelloSignature> = <template>
  <div class={{styles.container}}>
    {{t "hello.message" name=@name}}
  </div>
</template>;

export default Hello;
/* my-addon: src/components/hello.gts */
import type { TOC } from '@ember/component/template-only';
import { t } from 'ember-intl';

import styles from './hello.css';

<template>
  <div class={{styles.container}}>
    {{t "hello.message" name=@name}}
  </div>
</template> satisfies TOC<HelloSignature>;

The first variation (assigns the template to a variable, uses type annotation) may help with debugging and searching code, as the variable gives a name to the component. The second (no assignment, use of satisfies) is what Ember CLI currently uses in its blueprints.

b. Routes

In my-app, the index route renders <Hello>:

{{! my-app: app/templates/index.hbs }}
<div class={{this.styles.container}}>
  <Hello @name={{this.userName}}
</div>

We see that the controller provides styles and userName, a getter that returns some string.

1. Change the file extension to .gts. Surround the template with the <template> tag.

/* my-app: app/templates/index.gts */
+ <template>
  <div class={{this.styles.container}}>
    <Hello @name={{this.userName}}
  </div>
+ </template>

2. Enter strict mode. Instead of this, write @controller to indicate things from the controller. Just like before, @model refers to the model hook's return value.

/* my-app: app/templates/index.gts */
+ import { Hello } from 'my-addon';
+
<template>
-   <div class={{this.styles.container}}>
+   <div class={{@controller.styles.container}}>
-     <Hello @name={{this.userName}}
+     <Hello @name={{@controller.userName}}
  </div>
</template>

3. The prior code is enough for .gjs, but not for .gts. If the template includes @controller or @model, provide the signature (their types).

/* my-app: app/templates/index.gts */
+ import type { TOC } from '@ember/component/template-only';
import { Hello } from 'my-addon';
+ import type IndexController from 'my-app/controllers/index';
+
+ interface IndexSignature {
+   Args: {
+     controller: IndexController;
+     model: unknown;
+   };
+ }

<template>
  <div class={{@controller.styles.container}}>
    <Hello @name={{@controller.userName}}
  </div>
- </template>
+ </template> satisfies TOC<IndexSignature>;

4. (Optional) Remove unnecessary code. Here, we can colocate the stylesheet so that we rely less on the controller (with the aim of removing it).

/* my-app: app/templates/index.gts */
import type { TOC } from '@ember/component/template-only';
import { Hello } from 'my-addon';
import type IndexController from 'my-app/controllers/index';
+
+ import styles from './index.css';

interface IndexSignature {
  Args: {
    controller: IndexController;
    model: unknown;
  };
}

<template>
-   <div class={{@controller.styles.container}}>
+   <div class={{styles.container}}>
    <Hello @name={{@controller.userName}}
  </div>
</template> satisfies TOC<IndexSignature>;

We can even use a Glimmer component to remove states from the controller.

/* my-app: app/templates/index.gts */
import Component from '@glimmer/component';
import { Hello } from 'my-addon';

import styles from './index.css';

interface IndexSignature {
  Args: {
    controller: unknown;
    model: unknown;
  };
}

export default class IndexRoute extends Component<IndexSignature> {
  get userName(): string {
    return 'Zoey';
  }

  <template>
    <div class={{styles.container}}>
      <Hello @name={{this.userName}}
    </div>
  </template>
}

c. Tests

Last but not least, let's update the test file for <Hello>.

/* my-app: tests/integration/components/hello-test.ts */
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';

interface TestContext extends BaseTestContext {
  userName: string;
}

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });

  test('it renders', async function (this: TestContext, assert) {
    await render<TestContext>(
      hbs`
        <Hello @name={{this.userName}} />
      `,
    );

    assert.dom().hasText('Hello, Zoey!');
  });
});

1. Change the file extension, replace the hbs tags with <template>, then enter strict mode.

/* my-app: tests/integration/components/hello-test.gts */
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
- import { hbs } from 'ember-cli-htmlbars';
+ import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';

interface TestContext extends BaseTestContext {
  userName: string;
}

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });

  test('it renders', async function (this: TestContext, assert) {
-     await render<TestContext>(
+     await render(
-       hbs`
+       <template>
        <Hello @name={{this.userName}} />
-       `,
+       </template>,
    );

    assert.dom().hasText('Hello, Zoey!');
  });
});

The template inside render() is in strict mode, so glint no longer needs an extra TestContext to analyze render().

2. You may have used QUnit's beforeEach hook to define things that will be passed to the template (for all tests of a module). Currently, a bug in Ember causes assertions to fail if we keep using this to pass things to the template.

We can get around this issue (pun intended) in a few different ways. One is to create an alias of this, called self.

/* my-app: tests/integration/components/hello-test.gts */
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';

interface TestContext extends BaseTestContext {
  userName: string;
}

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });

  test('it renders', async function (this: TestContext, assert) {
+     const self = this;
+
    await render(
      <template>
-         <Hello @name={{this.userName}} />
+         <Hello @name={{self.userName}} />
      </template>,
    );

    assert.dom().hasText('Hello, Zoey!');
  });
});

Another is to destructure this so that we can pass things as variables.

/* my-app: tests/integration/components/hello-test.gts */
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';

interface TestContext extends BaseTestContext {
  userName: string;
}

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });

  test('it renders', async function (this: TestContext, assert) {
+     const { userName } = this;
+
    await render(
      <template>
-         <Hello @name={{this.userName}} />
+         <Hello @name={{userName}} />
      </template>,
    );

    assert.dom().hasText('Hello, Zoey!');
  });
});

A third option is to define variables at a global level (scoped to a test module).

/* my-app: tests/integration/components/hello-test.gts */
import { render } from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  const userName = 'Zoey';

  test('it renders', async function (assert) {
    await render(
      <template>
        <Hello @name={{userName}} />
      </template>,
    );

    assert.dom().hasText('Hello, Zoey!');
  });
});

Finally, to prevent misusing global variables, to customize test setups, or to facilitate the removal of dead code, you can define things locally instead.

/* my-app: tests/integration/components/hello-test.gts */
import { render } from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders', async function (assert) {
    const userName = 'Zoey';

    await render(
      <template>
        <Hello @name={{userName}} />
      </template>,
    );

    assert.dom().hasText('Hello, Zoey!');
  });
});

3. ember-codemod-add-template-tags

Time to automate. In an earlier post, I revealed a codemod that performs static code analysis and supports apps, addons, and monorepos.

# From the workspace or package root
pnpx ember-codemod-add-template-tags

In case you'd like to incrementally migrate, the codemod also provides the options --convert and --folder.

# Components and tests only
pnpx ember-codemod-add-template-tags --convert components tests

# `ui/form` folder only
pnpx ember-codemod-add-template-tags --convert components tests --folder ui/form

Guess what? The codemod performs the exact steps that you learned above.


This content originally appeared on DEV Community and was authored by Isaac Lee


Print Share Comment Cite Upload Translate Updates
APA

Isaac Lee | Sciencx (2025-10-13T12:20:47+00:00) Migrating to <template> tag. Retrieved from https://www.scien.cx/2025/10/13/migrating-to-template-tag/

MLA
" » Migrating to <template> tag." Isaac Lee | Sciencx - Monday October 13, 2025, https://www.scien.cx/2025/10/13/migrating-to-template-tag/
HARVARD
Isaac Lee | Sciencx Monday October 13, 2025 » Migrating to <template> tag., viewed ,<https://www.scien.cx/2025/10/13/migrating-to-template-tag/>
VANCOUVER
Isaac Lee | Sciencx - » Migrating to <template> tag. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/10/13/migrating-to-template-tag/
CHICAGO
" » Migrating to <template> tag." Isaac Lee | Sciencx - Accessed . https://www.scien.cx/2025/10/13/migrating-to-template-tag/
IEEE
" » Migrating to <template> tag." Isaac Lee | Sciencx [Online]. Available: https://www.scien.cx/2025/10/13/migrating-to-template-tag/. [Accessed: ]
rf:citation
» Migrating to <template> tag | Isaac Lee | Sciencx | https://www.scien.cx/2025/10/13/migrating-to-template-tag/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.