Skip to content

TutorialFor theme developersDrupal 11

Build your first theme 2: Twig templates and suggestions

In part 2 of the theme tutorial you take control of markup: you override the node template, then use Twig’s debug output to discover the more specific template names Drupal looks for, and target node teasers with one of them.

Two templates in First theme:

  • templates/node.html.twig, your own override of the node template that ships with the Node module,
  • templates/node--teaser.html.twig, a template suggestion that Drupal picks automatically whenever a node renders in the teaser view mode.

You start from the theme as part 1 left it: installed, default, with your own regions.

  • First theme installed and set as default per part 1.
  • A couple of article nodes, so the front page shows teasers.

Drupal compiles Twig templates to PHP and caches the result aggressively, which is exactly wrong while you are editing them. Core ships a development mode that recompiles templates on every change and annotates the page source with HTML comments naming each template and its alternatives.

Go to Administer → Configuration → Development → Development settings/admin/config/development/settings, check Twig development mode, leave the two sub-options it reveals (Twig debug mode and Disable Twig cache) checked, and select Save settings. The debugging guide covers this page in depth, including how to toggle the same settings from the command line.

Every theme hook a module defines comes with a default template; a theme overrides one by shipping a template file with the same name. Drupal finds template files anywhere below templates/, by name alone.

The starterkit copy of the theme already contains one at templates/content/node.html.twig. Delete that file, so the theme has exactly one node template, and create templates/node.html.twig:

web/themes/custom/first_theme/templates/node.html.twig
{#
/**
* @file
* First theme's override of the node template.
*
* Drupal finds this file by name: node.html.twig in the active theme wins
* over the copy that ships with the node module.
*
* Available variables include:
* - node: The node entity.
* - label: The (possibly hidden) node title.
* - content: The render array of all visible fields. Render it whole with
* {{ content }} or pick fields with {{ content.field_name }}.
* - attributes / title_attributes / content_attributes: HTML attribute
* objects for the wrapper, title and content elements.
* - page: TRUE when the node is shown as a full page.
*
* @see template_preprocess_node()
*/
#}
<article{{ attributes.addClass('node', 'node--' ~ node.bundle|clean_class) }}>
{{ title_prefix }}
{% if label and not page %}
<h2{{ title_attributes }}>
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
</h2>
{% endif %}
{{ title_suffix }}
<div{{ content_attributes.addClass('node__content') }}>
{{ content }}
</div>
</article>

Two things in this template repay attention:

  • content is a render array holding every visible field. {{ content }} renders all of it; {{ content.body }} would render one field. Render arrays, not raw values, are what templates receive, and printing them is what triggers rendering. The render pipeline explains why.
  • attributes, title_attributes, and content_attributes are Attribute objects. Methods like addClass() merge your classes with whatever modules added during preprocessing, instead of overwriting it. The |clean_class filter (one of Drupal’s Twig additions in TwigExtension) turns a bundle name like blog_post into a safe CSS class.

New template files are one of the few things Twig development mode does not pick up, because the list of discovered templates lives in the theme registry cache:

Terminal window
ddev drush cache:rebuild

Step 3: Read the suggestions and target teasers

Section titled “Step 3: Read the suggestions and target teasers”

Look closer at the FILE NAME SUGGESTIONS comment above an article teaser:

<!-- FILE NAME SUGGESTIONS:
▪️ node--1--teaser.html.twig
▪️ node--1.html.twig
▪️ node--article--teaser.html.twig
▪️ node--article.html.twig
▪️ node--teaser.html.twig
✅ node.html.twig
-->

This is the suggestion list: alternative template names Drupal checked, most specific first, with ✅ marking the one it used. The Node module provides these alternatives, and the pattern is readable directly off the list: by view mode, by bundle, by ID, and combinations. Create the file matching “any node in the teaser view mode”, templates/node--teaser.html.twig:

{# First pass. Part 4 rebuilds this around the card component. #}
<article{{ attributes.addClass('node', 'node--teaser') }}>
<h3><a href="{{ url }}" rel="bookmark">{{ label }}</a></h3>
<div{{ content_attributes }}>{{ content }}</div>
</article>

Rebuild caches once more so the registry sees the new file. Full article pages keep using node.html.twig; teasers switch to the new template. You changed nothing else: picking the most specific existing template is the theme system’s job.

You will eventually want a suggestion that core does not offer, and there is a split in how the two sides of Drupal provide them:

Modules define suggestions by implementing hook_theme_suggestions_HOOK() as a #[Hook] attribute method in a class under src/Hook/. That is exactly where your teaser suggestion came from; the Node module implements it in its NodeThemeHooks class:

// Fragment: how a module provides suggestions (src/Hook class).
#[Hook('theme_suggestions_node')]
public function themeSuggestionsNode(array $variables): array {
return ['node__' . $variables['elements']['#node']->bundle()];
}

Themes cannot use #[Hook] classes: core’s hook attribute documentation states that hooks implemented by themes must remain procedural. A theme adds or reorders suggestions with hook_theme_suggestions_HOOK_alter() as a plain function in its .theme file:

// Fragment: first_theme.theme. Theme hooks stay procedural functions.
function first_theme_theme_suggestions_node_alter(array &$suggestions, array $variables): void {
$suggestions[] = 'node__highlighted';
}

Suggestions are machine names with double underscores (node__teaser); the matching file names use double hyphens (node--teaser.html.twig). A suggestion must start with the base hook name plus __, or Drupal discards it; Twig development mode lists discarded ones under INVALID FILE NAME SUGGESTIONS. First theme does not need custom suggestions, so its generated first_theme.theme keeps only the starterkit’s preprocess function for now (part 6 adds one of its own); object-oriented hooks covers the module side in full.

The theme registry caches the list of template files per theme hook. Twig development mode recompiles changed templates but does not rescan for new files: run ddev drush cache:rebuild after adding or renaming any template.

Either Twig development mode is off, or you are looking at a cached page. The page cache for anonymous visitors stores pages rendered earlier; rebuild caches or check as a logged-in user.

Twig\Error\SyntaxError after editing a template

Section titled “Twig\Error\SyntaxError after editing a template”

The error page names the template file and line. Typical causes: an unclosed {% if %} or {% for %} block, or {{ }} around a tag that needs {% %}. Fix the line; with the Twig cache disabled the next reload recompiles immediately.

The same template appears twice in my theme

Section titled “The same template appears twice in my theme”

Two files with the same name in different subdirectories of templates/ are ambiguous; which one wins depends on scan order, not on your intent. Keep exactly one, as step 2 did by deleting the starterkit copy.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.