Drupal Custom Theme from Scratch with Twig in Drupal 11

Drupal 11's theming system uses Twig 3, a modern templating engine that keeps logic out of templates and makes overriding core HTML straightforward. The recommended starting point is the Starterkit — a generator that scaffolds a fully functional sub-theme from Drupal core's stable base, so you own all the HTML from day one without inheriting any styles you did not choose.

This guide builds a production-ready custom theme: Starterkit generation, .info.yml anatomy, CSS/JS library system, template overrides with Twig, preprocess hooks, and common patterns for Drupal 11 field rendering.

Generate the Theme with Starterkit

# From the Drupal root (one level above /web)
php web/core/scripts/drupal generate-theme \
    --starterkit starterkit_theme \
    --name "My Site" \
    --machine-name my_site \
    web/themes/custom/my_site

This creates:

web/themes/custom/my_site/
├── my_site.info.yml
├── my_site.libraries.yml
├── my_site.theme          ← PHP preprocess hooks
├── logo.svg
├── favicon.ico
├── css/
│   └── base/
│       └── base.css
├── js/
│   └── my_site.js
├── images/
└── templates/
    ├── block/
    ├── content/
    ├── field/
    ├── form/
    ├── layout/
    ├── misc/
    ├── navigation/
    ├── page/
    └── views/

Enable the theme:

drush theme:enable my_site -y
drush config:set system.theme default my_site -y
drush cr

The .info.yml File

# my_site.info.yml
name: 'My Site'
type: theme
description: 'Custom Drupal 11 theme for My Site.'
package: 'Custom'
core_version_requirement: ^11

# Starterkit does not use a base_theme — it owns all templates
base_theme: false

# Libraries loaded on every page
libraries:
  - my_site/global-styles
  - my_site/global-scripts

# Libraries loaded conditionally can be attached in templates or preprocess hooks
# libraries-override and libraries-extend modify contrib/core libraries

libraries-override:
  claro/base: false   # Remove admin theme base library if admin on same theme

# Regions define the structural slots available in page.html.twig
regions:
  header:        'Header'
  primary_menu:  'Primary navigation'
  breadcrumb:    'Breadcrumb'
  highlighted:   'Highlighted'
  content:       'Content'
  sidebar:       'Sidebar'
  footer:        'Footer'

The Library System (.libraries.yml)

# my_site.libraries.yml

global-styles:
  css:
    theme:
      css/base/base.css:   {}
      css/layout/layout.css: {}
      css/components/components.css: {}

global-scripts:
  js:
    js/my_site.js: {}
  dependencies:
    - core/drupal
    - core/once

# A component library (attached conditionally)
image-gallery:
  css:
    component:
      css/components/gallery.css: {}
  js:
    js/gallery.js: {}
  dependencies:
    - core/drupal

Attach a conditional library in a template:

{# In a Twig template that needs the gallery component #}
{{ attach_library('my_site/image-gallery') }}

Or from a preprocess hook:

// my_site.theme
function my_site_preprocess_node(&$variables) {
  $node = $variables['node'];
  if ($node->bundle() === 'gallery') {
    $variables['#attached']['library'][] = 'my_site/image-gallery';
  }
}

Template Overrides: Naming Convention

Drupal selects templates using a candidate-file algorithm. Templates with more specific names take priority. Common patterns:

Template fileApplies to
page.html.twigAll pages
page--front.html.twigFront page only
node.html.twigAll nodes
node--article.html.twigNodes of type article
node--article--full.html.twigArticle nodes in full view mode
block.html.twigAll blocks
block--system-branding-block.html.twigThe site branding block
field--field-image.html.twigThe field_image field on all entities
views-view--my-view.html.twigA specific Views view

Copy the source template from core, then modify it — never edit core files directly:

# Find where a template lives in core
find web/core -name "node.html.twig" 2>/dev/null

# Copy to your theme's templates directory
cp web/core/themes/starterkit_theme/templates/content/node.html.twig \
   web/themes/custom/my_site/templates/content/node.html.twig

drush cr   # Clear cache so Drupal picks up the new template

page.html.twig: Building Your Layout

{# templates/layout/page.html.twig #}
<div class="layout-container">

  <header class="site-header" role="banner">
    {{ page.header }}
    <nav class="site-nav" aria-label="{{ 'Main navigation'|t }}">
      {{ page.primary_menu }}
    </nav>
  </header>

  {% if page.breadcrumb %}
    <nav class="breadcrumb" aria-label="{{ 'Breadcrumb'|t }}">
      {{ page.breadcrumb }}
    </nav>
  {% endif %}

  <main role="main" id="main-content">
    <a href="#main-content" class="visually-hidden focusable skip-link">
      {{ 'Skip to main content'|t }}
    </a>

    {% if page.highlighted %}
      <div class="highlighted">{{ page.highlighted }}</div>
    {% endif %}

    <div class="layout-main-wrapper">
      <div class="layout-main">{{ page.content }}</div>
      {% if page.sidebar %}
        <aside class="layout-sidebar">{{ page.sidebar }}</aside>
      {% endif %}
    </div>
  </main>

  <footer class="site-footer">
    {{ page.footer }}
  </footer>

</div>

node--article--full.html.twig

{# templates/content/node--article--full.html.twig #}
{{ attach_library('my_site/article-styles') }}

<article{{ attributes.addClass('article', 'article--full') }}>

  <header class="article__header">
    {{ title_prefix }}
    <h1{{ title_attributes.addClass('article__title') }}>
      {{ label }}
    </h1>
    {{ title_suffix }}

    <div class="article__meta">
      <time datetime="{{ node.created.value|date('Y-m-d') }}">
        {{ date_formatted }}
      </time>
      {% if display_submitted %}
        <span class="article__author">{{ 'by'|t }} {{ author_name }}</span>
      {% endif %}
    </div>
  </header>

  {% if content.field_image %}
    <div class="article__hero">
      {{ content.field_image }}
    </div>
  {% endif %}

  <div{{ content_attributes.addClass('article__body') }}>
    {{ content.body }}
  </div>

  {% if content.field_tags %}
    <footer class="article__footer">
      <div class="article__tags">{{ content.field_tags }}</div>
    </footer>
  {% endif %}

</article>

Preprocess Hooks in my_site.theme

<?php
// my_site.theme

/**
 * Implements hook_preprocess_HOOK() for html templates.
 */
function my_site_preprocess_html(&$variables) {
  // Add a body class for each route
  $route_name = \Drupal::routeMatch()->getRouteName();
  $variables['attributes']['class'][] = 'route--' . str_replace('.', '-', $route_name);

  // Add viewport meta tag
  $variables['#attached']['html_head'][] = [
    [
      '#tag'        => 'meta',
      '#attributes' => [
        'name'    => 'viewport',
        'content' => 'width=device-width, initial-scale=1',
      ],
    ],
    'viewport',
  ];
}

/**
 * Implements hook_preprocess_HOOK() for node templates.
 */
function my_site_preprocess_node(&$variables) {
  $node = $variables['node'];

  // Make the formatted creation date available as a simple variable
  $variables['date_formatted'] = \Drupal::service('date.formatter')
    ->format($node->getCreatedTime(), 'custom', 'F j, Y');

  // Add reading time estimate to articles
  if ($node->bundle() === 'article' && $node->hasField('body')) {
    $body = $node->get('body')->value ?? '';
    $word_count = str_word_count(strip_tags($body));
    $variables['reading_time'] = (int) ceil($word_count / 200); // 200 wpm average
  }
}

/**
 * Implements hook_preprocess_HOOK() for block templates.
 */
function my_site_preprocess_block(&$variables) {
  // Remove contextual links from specific blocks on the front end
  if (isset($variables['elements']['#id'])
      && $variables['elements']['#id'] === 'my_site_main_menu') {
    unset($variables['elements']['#contextual_links']);
  }
}

Twig Best Practices for Drupal 11

Use content variables, not field API calls

{# Wrong: bypasses field formatters and display settings #}
{{ node.field_image.entity.uri.value }}

{# Correct: renders through the configured formatter #}
{{ content.field_image }}

Conditional output without empty checks

{# Drupal render arrays need the empty() check OR the Twig empty filter #}
{% if content.field_tags|render %}
  <div class="tags">{{ content.field_tags }}</div>
{% endif %}

Translation and pluralisation

{# Single string #}
{{ 'Published'|t }}

{# With replacement #}
{{ 'Published by @name'|t({'@name': author_name}) }}

{# Plural #}
{{ reading_time }} {{ 'min read'|t }}

Debugging templates — find which template is used

# Enable Twig debugging in development.services.yml
parameters:
  twig.config:
    debug: true
    auto_reload: true
    cache: false

With Twig debug enabled, HTML source comments show the template candidates and the winner for each component — invaluable when creating overrides.

Summary Checklist

  • Generate your theme with php core/scripts/drupal generate-theme from the Starterkit — do not start from scratch.
  • Set base_theme: false — you own all templates, no inherited styles to fight.
  • Declare all CSS and JS in .libraries.yml; never use <link> or <script> tags directly in templates.
  • Use specific template names (e.g. node--article--full.html.twig) to target only the context you need.
  • Copy templates from core before modifying them; always drush cr after adding a new template.
  • Use preprocess hooks for logic; keep templates pure output with minimal conditions.
  • Use {{ content.field_name }} not raw field values — it respects formatters and display settings.
  • Enable Twig debug in development to see which template file is rendering each component.
  • Use {{ content.field_tags|render }} as the condition check for render arrays rather than bare {% if content.field_tags %}.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.