Introduction
I’ve worked on high-traffic sites and seen how targeted template changes reduce front-end weight and improve maintainability. Custom templates in Drupal let you control markup, accessibility, and performance at the theme layer.
This tutorial focuses on Drupal 10 and shows practical steps you can apply immediately: creating a basic node template with Twig, adding and attaching CSS/JS through libraries, and using preprocess hooks to keep logic out of templates. It also includes troubleshooting and security guidance so you can apply these changes safely on production sites.
Introduction to Drupal Templates: What You Need to Know
Understanding Drupal's Template System
Drupal templates determine the HTML structure output for entities such as nodes, blocks, views, and pages. Templates live in the theme's templates/ directory and are rendered by Twig. Knowing the template naming conventions and when to use preprocess hooks makes it easy to keep logic out of templates and maintain performance.
- Templates control layout and markup for specific render contexts.
- Twig is the templating engine used in Drupal 8/9/10.
- Override templates in your active theme's templates/ folder.
- Prefer preprocess functions to inject variables instead of heavy logic in Twig.
Setting Up Your Drupal Environment for Template Development
Essential Tools and Software
Use a reproducible local environment that matches your production PHP and webserver. Common tooling used by Drupal developers includes:
- Lando (recommended for reproducible containers) — widely used with Drupal projects.
- DDEV — another popular Docker-based local environment for PHP applications.
- MAMP / XAMPP — useful for simple local setups on macOS/Windows.
- Drush (e.g., Drush 10/11) for command-line site operations.
Verify your PHP version before installing Drupal 10; Drupal 10 requires PHP 8.1 or later. Check PHP on your machine with:
php -v
Download Drupal from the official project site and follow the installer. Use version control (Git) and create a dev branch for theme work to avoid touching production directly.
Choosing a Base Theme
When starting a new theme or customizing templates, choose an appropriate base theme. Drupal core provides common starting points such as Stable, Classy, and the Starterkit approach. Picking a base theme affects available CSS classes, existing templates, and how much you need to override.
- Stable: minimal markup, useful when you want full control over output.
- Classy: provides many default utility classes and markup patterns used by core components.
- Starterkit (theme scaffold): for creating a custom theme that copies core template structure into your theme for safe editing.
Recommendation: if you want lean markup and full control, base on Stable or start with Starterkit and take only the templates you modify. For faster results with consistent class names, Classy can reduce the amount of work.
For core docs and theme starter guidance, see the official Drupal project site: https://www.drupal.org/.
Understanding the Drupal Theming Layer and Template Hierarchy
Exploring Theming Layer Basics
The Drupal theming layer combines render arrays, theme hooks, Twig templates, and asset libraries. The template naming hierarchy enables granular overrides — for example, Drupal will use node--article.html.twig for article nodes before falling back to node.html.twig.
- Render arrays provide structured data to the theming system.
- Preprocess functions (hook_preprocess_HOOK) allow variable manipulation before rendering.
- Theme libraries defined in
THEME.libraries.ymlmanage CSS/JS assets and dependencies.
hook_theme() and Custom Theme Hooks
In addition to file-based Twig templates, modules and themes can register theme hooks using hook_theme(). This allows you to define named renderable elements, expose variables to templates, and point Drupal to template files stored in a module or theme.
Example: register a custom theme hook in a module so Drupal knows where to find the template and which variables to pass.
[
'variables' => ['items' => NULL],
'template' => 'my-custom-item',
'path' => drupal_get_path('module', 'mymodule') . '/templates',
],
];
}
After implementing hook_theme(), place my-custom-item.html.twig in the module's templates/ directory. Use render arrays with the #theme key (for example ['#theme' => 'my_custom_item', '#items' => $data]) so Drupal invokes your template.
Advanced Twig Features
For advanced templating needs, you can add custom Twig functions, filters, or extensions. Use them sparingly — push logic to preprocess/PHP when possible, but extensions are useful for reusable presentation helpers (for example, obfuscating emails or returning computed asset versions).
Example: Add a Twig filter to obfuscate email addresses
Implement a small Twig extension in a custom module. This example shows the PHP class and a service registration. Use these only in development with proper reviews for security.
namespace Drupal\mymodule\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class ObfuscateExtension extends AbstractExtension {
public function getFilters() {
return [
new TwigFilter('obfuscate_email', [$this, 'obfuscateEmail']),
];
}
public function obfuscateEmail($email) {
return str_replace('@', ' [at] ', $email);
}
}
Service definition in mymodule.services.yml (module root):
services:
mymodule.twig.obfuscate_extension:
class: Drupal\mymodule\Twig\ObfuscateExtension
tags:
- { name: twig.extension }
Usage in Twig:
{{ user_mail|obfuscate_email }}
Notes:
- Keep extensions focused and small; heavy logic belongs in PHP or services used by preprocess functions.
- Always document new Twig functions/filters and add unit tests where possible.
Template Rendering Flow (Diagram)
Quick mapping: the browser request is routed → controller/entity builds render arrays → theme layer runs preprocess hooks and selects templates → Twig renders HTML and assets are attached. The diagram below visualizes these stages and their relationships.
Creating Your First Custom Template: A Hands-On Tutorial
Setting Up Your Custom Template
Locate your active theme directory (example: themes/custom/mytheme). Create or open templates/ inside your theme. When overriding a node template for the "article" content type, the filename should be node--article.html.twig.
Clear caches after creating or editing templates so Drupal picks up file changes:
drush cr
Enable Twig debugging during development to see which template suggestions are available. In your sites/development.services.yml (or sites/default/services.yml for local dev), set the twig config as follows (only enable on local/dev):
parameters:
twig.config:
debug: true
auto_reload: true
cache: false
With twig debug enabled, view the page source in the browser to find template suggestions and confirm which template Drupal is using.
Twig Debugging (Dev Only) — WARNING
Important: enable Twig debugging only in local or development environments. Twig debug exposes template paths, suggestions, and some variable structures in page source — which can leak implementation details and sensitive paths. Additionally, leaving Twig debug and cache disabled harms performance.
For production, ensure twig debug is disabled and caching is enabled. Example production configuration (set in your production services.yml or environment-specific configuration):
parameters:
twig.config:
debug: false
auto_reload: false
cache: true
After changing these settings, always clear caches (drush cr) and confirm debug output is no longer present in page source. Treat enabling debug as a temporary developer tool, not a runtime mode for live sites.
Step-by-Step Example: node--article.html.twig
Below is a minimal starting template for an article node. It demonstrates printing the title and rendered content using the standard Twig variables. Place this file at themes/custom/mytheme/templates/node--article.html.twig.
{# node--article.html.twig #}
<article{{ attributes.addClass('node', 'node--article') }}>
{% if label %}
<h1{{ title_attributes.addClass('node__title') }}>{{ label }}</h1>
{% endif %}
<div class="node__meta">
{{ author_picture }}
<span class="node__submitted">{{ author_name }} — {{ created }}</span>
</div>
<div class="node__content">
{{ content|without('comments', 'links') }}
</div>
{% if content.comments %}
<div class="node__comments">{{ content.comments }}</div>
{% endif %}
</article>
Notes:
- Use
|without()to exclude regions fromcontentwhen rendering fields selectively. - Twig auto-escapes output by default; avoid using the raw filter unless you have sanitized HTML.
If you need a custom field displayed differently, use a preprocess function to prepare the variable (see the Best Practices section for a preprocess example).
Enhancing Templates with CSS and JavaScript for Better UX
Adding Styles and Scripts via Theme Libraries
Define assets in your theme's mytheme.libraries.yml and attach them globally in mytheme.info.yml or attach per-template via #attached in preprocess. Example files below show a small CSS and JS snippet and how to register them.
Create a file themes/custom/mytheme/css/custom.css with the following minimal styles:
/* custom.css */
.custom-article {
background-color: #f0f0f0;
padding: 18px;
border-radius: 6px;
}
.node__title {
color: #333333;
font-size: 26px;
}
Create a file themes/custom/mytheme/js/custom.js with this simple script:
// custom.js
(function (Drupal, drupalSettings) {
'use strict';
console.log('Custom script loaded!');
})(Drupal, drupalSettings);
Example mytheme.libraries.yml:
global-styling:
css:
theme:
css/custom.css: {}
js:
js/custom.js: {}
dependencies:
- core/drupal
- core/jquery
Attach the library globally in mytheme.info.yml so it loads on every page:
libraries:
- mytheme/global-styling
Alternatively, attach the library only for article nodes in a preprocess function:
function mytheme_preprocess_node(array &$variables) {
if ($variables['node']->getType() === 'article') {
$variables['#attached']['library'][] = 'mytheme/global-styling';
}
}
Security and performance tips for assets:
- Avoid inline scripts/styles when possible to make a stricter Content-Security-Policy (CSP) feasible.
- Minify and aggregate CSS/JS for production (Drupal aggregation settings or build tools such as PostCSS).
- Use the library dependency mechanism to ensure jQuery or Drupal behaviors are loaded in the correct order.
Performance Optimization Beyond Basic Aggregation
Beyond Drupal’s built-in aggregation, focus on delivering only what’s needed per page and deferring non-critical work.
- Lazy-load images and media: Use the
loading="lazy"attribute and Drupal responsive image styles where appropriate. - Critical CSS: Extract critical CSS for above-the-fold content and defer non-critical styles. Use build tools (PostCSS/CSSNano) to extract and inline only essential rules for the initial render.
- Use BigPipe for progressive delivery: BigPipe (core module) can stream cacheable content first and replace placeholders with personalized or heavy content later.
- Component-level caching: Add cache metadata to render arrays (
#cache) with appropriate contexts and tags to maximize reuse while ensuring invalidation works correctly. - Image optimization: Produce multiple image derivatives and use responsive image styles; integrate an image-optimization pipeline (CI or on-upload) that creates WebP or AVIF variants for modern browsers.
Example: Add simple cache metadata on a render array in PHP so the template output is cached properly:
$build = [
'#theme' => 'my_custom_item',
'#items' => $items,
'#cache' => [
'contexts' => ['user.roles:authenticated'],
'tags' => ['node_list'],
'max-age' => 3600,
],
];
Real-World Debugging Techniques
Inspecting complex render arrays and template variables is common during theming. Use the right tools and guard them to development environments only.
- Devel & Kint: The Devel module provides
kint()and the Twigkint()function to inspect variables in templates. Example in Twig:{{ kint(content.field_my_complex) }}. Only enable Devel/Kint on non-production sites. - Twig
dump(): Use{{ dump(variable) }}in templates to print a structured representation. Wrap with environment checks to avoid exposing data in production. - Server-side debugging: Use
\Kint::dump($var);or simpleerror_log(print_r($var, TRUE));in preprocess to inspect variables during request processing. - Browser devtools & network: Inspect CSS, markup, and asset hashes. Verify library attachments by checking page HTML and network requests for the CSS/JS files.
Example pattern to keep debug code local-only in Twig:
{% if app.environment == 'dev' %}
{{ kint(content) }}
{% endif %}
Note: Ensure any debugging statements are removed or disabled before deploying to production to avoid information disclosure and performance overhead.
Theme Inheritance / Sub-theming
Sub-themes allow you to extend a base theme and override or add templates and assets without copying the entire base theme. Use sub-themes when you want to inherit a stable set of templates and styles while customizing a subset.
Minimal mysubtheme.info.yml example that inherits from Stable or Classy:
name: My Subtheme
type: theme
base theme: classy
description: "A subtheme that customizes a few templates and styles."
core_version_requirement: ^10
libraries:
- mysubtheme/global-styling
Place overrides in themes/custom/mysubtheme/templates/ and define only the libraries and templates you change. This keeps upgrades simpler since the base theme can receive updates independently.
When to use sub-themes:
- You need consistent base behavior across multiple projects but with small customizations.
- You want to keep core or contributed theme updates separate from project-specific changes.
Best Practices and Troubleshooting for Custom Drupal Templates
Implementing Best Practices
Follow these concrete guidelines to keep theme code maintainable and secure:
- Name templates using Drupal conventions (e.g.,
node--content-type.html.twig). - Keep heavy logic in module PHP or preprocess functions; templates should only handle presentation.
- Use Twig’s escaping and the
without()filter to avoid printing undesired fields. - Document theme changes and include a changelog in the theme directory for team handoffs.
Example preprocess function to add a custom class and a derived variable to the template (fixed and using recommended APIs):
function mytheme_preprocess_node(array &$variables) {
$node = $variables['node'];
if ($node->getType() === 'article') {
// Add a CSS class used in the Twig template
if (isset($variables['attributes']) && method_exists($variables['attributes'], 'addClass')) {
$variables['attributes']->addClass('custom-article');
}
// Add a lightweight derived variable for display
$variables['display_date'] = \Drupal::service('date.formatter')->format($node->getCreatedTime(), 'long');
}
}
Troubleshooting Common Issues
When changes don't appear, verify these common causes and fixes:
- Cache: Run
drush cror clear caches from the admin UI. - Twig debug: Enable twig debug to view template suggestions and confirm which template renders the page.
- Wrong file path/name: Confirm filename and that it's in
themes/custom/mytheme/templates/. - Asset not loading: Check that the library is declared in
mytheme.libraries.ymland attached viainfo.ymlor preprocess. - Permission issues: Ensure file permissions allow the web server user to read theme files.
Use browser devtools to inspect markup and network requests, and check Drupal logs (Admin > Reports > Recent log messages) for PHP notices.
Key Takeaways
- Use Twig and template naming conventions to target specific output types like
node--article.html.twig. - Define styles and scripts in
THEME.libraries.ymland attach them viaTHEME.info.ymlor preprocess for modular loading. - Keep logic in preprocess functions to keep templates clean and maintainable.
- Enable twig debug and use Drush for quick cache rebuilds during theme development, but never leave debug enabled in production.
Frequently Asked Questions
- What is the best way to start creating custom templates in Drupal?
- Start with Twig fundamentals, confirm twig debugging is enabled for template suggestions, and create a minimal override (for example
node--article.html.twig) in your theme. Use local environments like Lando or DDEV for reliable testing. - How can I optimize my Drupal templates for performance?
- Minimize DOM depth, combine and aggregate CSS/JS for production, leverage Drupal’s cache metadata on render arrays, and move complex computation to background processes or preprocess hooks so rendering stays fast.
Conclusion
Custom Drupal templates let you control markup, accessibility, and asset loading. The recommended workflow is: create a minimal Twig override, use preprocess hooks for variables and attachments, define assets in .libraries.yml, and validate changes with twig debug and Drush. This approach keeps templates maintainable and production-safe.
For more theme documentation and community resources, refer to the official Drupal project site: https://www.drupal.org/.