Andrew Welch
Posted on March 23, 2020
An Effective Twig Base Templating Setup
A good base templating setup for your Craft CMS Twig templates provides a stable, solid foundation on which to build your projects
Andrew Welch / nystudio107
Twig is a fantastic templating language that features multiple inheritance of layout templates, and is optimized to be an easy to use presentation layer.
This article discusses an effective Twig base templating setup that I have found to work extremely well for me in my Craft CMS websites.
However, even if you use another CMS that uses Twig like Drupal or Grav, or you use another templating language entirely like Blade or Antlers, the principles discussed here still apply.
The key thing to note here is that Twig is a templating language, and as such it should not be used for complicated business or intensive calculations.
Not that it can’t handle either (it can) but rather that it shouldn’t.
If you’re unclear as to why, read about why Twig was created to begin with in the Templating Engines in PHP article.
It’s all about that base
Over the years, on a vast array of software projects of all shapes and sizes, I’ve seen developers chasing the holy grail of code reusability.
Often times it ends up being that they spend an inordinate amount of time creating “the one high level framework to rule them all”, only to be confused when reality butts in its ugly head.
I try to be more practical about which things I will actually re-use (and some would say less ambitious).
Websites created in Craft CMS tend to be more on the bespoke side of things, otherwise they might be better done in a more cookie-cutter system anyway.
So what I re-use are very fundamental things like the build system (discussed in the An Annotated webpack 4 Config for Frontend Web Development article), and a base templating system.
If you’re going to build anything of substance, it’s crucial that the base it’s built on is robust.
So here’s what I want out of a base templating system:
- The ability to use it unmodified on a wide variety of projects
- One template that can be used both as a web page, and as popup modals via AJAX / XHR
- Implement core features for me, without restricting me in terms of flexibility
- Allow for creating Google AMP pages, if the project warrants it
Often I see developers making templates that inherit from just one layout, or if they use multiple layouts, it’s still a single inheritance chain.
Twig allows for more than that. So let’s see how one approach to leverage this might work.
SEO & popup modals
Many of the points mentioned in the previous section are largely self-explanatory, but point 2 deserves more explanation. It’s all about templates working both as web pages and popup modals loaded in via AJAX / XHR.
I frequently work with Jonathan Melville of Code MDD on projects, and he often does designs that have content in popup modals.
For example, if you go to the Seaside Events page you’ll see a number of events listed, and if you click on an event, you’ll see the event details in a popup modal:
This is great, and gives it a nice app-ish feel, allowing the user to view multiple events without leaving the original page.
But for SEO reasons, as well as for canonical page linking reasons, the same content can also be found on its own unique page: Seaside Farmer’s Market — Saturdays in November:
This is ideally what we want to be able to do automatically: have the same core content be displayable both with and without the web page “chrome” around it.
And this is one of the things that the base templating setup does.
The overall structure
Here’s an overview of what this base templating system looks like. It may seem involved, but we’ll break it down:
The orange rounded rectangles represent templates that will be in your templates/_layouts/ directory, and may vary from project to project.
The blue rectangles represent boilerplate templates that will be in your templates/_boilerplate/_layouts/ directory, and won’t change from project to project.
If at this point you’re someone who learns better by real-world examples, the exact base templating system described here is used in the MIT-licensed devMode.fm website Github repo.
Feel free to check it out; it’s also used in the nystudio107/craft boilerplate setup.
Meanwhile, everyone else, read on! We’re going to break down each template.
PROJECT: will prefix each template that may vary from project to project
BOILERPLATE: will prefix each template that stays the same from project to project
Here we go…
PROJECT: global-variables.twig
Due to Twig’s Processing Order & Scope, if we want to have global variables that are always available in all of our templates, they need to be defined in the root template that all others extends from.
Since these globals can vary from project to project, they are not part of the boilerplate, but they are required for the setup.
{# -- Root global variables that all templates inherit from -- #}
{# -- This allows for defining site-wide Twig variables as needed -- #}
{% spaceless %}
{# -- Prefetch & preconnect headers and links -- #}
{% set prefetchUrls = [
alias("@assetsUrl"),
] %}
{# -- General global variables -- #}
{% set baseUrl = alias('@assetsUrl') ~ '/' %}
{% set gaTrackingId = getenv('GA_TRACKING_ID') %}
{# -- Twig output from the render; this must be in a block -- #}
{% block htmlPage %}
{% endblock %}
{% endspaceless %}
The global-variables.twig template has the following blocks that can be overridden by its children:
- htmlPage — a block that encompasses the entire rendered HTML page
BOILERPLATE: base-web-layout.twig
Every webpage, whether a regular web page or a Google AMP page inherits from this template. The setup may look a little weird, but it’s done this way so that child templates can override bits like the opening <html> tag if they need to:
{# -- Base web layout template that all web requests inherit from -- #}
{% extends "_layouts/global-variables.twig" %}
{%- block htmlPage -%}
{% minify %}
<!DOCTYPE html>
{% block htmlTag %}
<html lang="{{ craft.app.language |slice(0,2) }}">
{% endblock htmlTag %}
{% block headTag %}
<head>
{% endblock headTag %}
{% include "_boilerplate/_partials/head-meta.twig" %}
{# -- Page content that should be included in the <head> -- #}
{% block headContent %}
{% endblock headContent %}
</head>
{% block bodyTag %}
<body>
{% endblock bodyTag %}
{# -- Page content that should be included in the <body> -- #}
{% block bodyContent %}
{% endblock bodyContent %}
</body>
</html>
{% endminify %}
{%- endblock htmlPage -%}
Since this is a base template that all other web pages inherit from, if we wanted to do full page caching using the Craft Cache tag, we could wrap that around the {% minify %}
tags here.
The base-web-layout.twig template has the following blocks that can be overridden by its children:
- htmlTag — the <html> tag, which child templates might need to override
- headTag — the <head> tag, which child templates might need to override
- headContent — whatever tags need to go into the <head>
- bodyTag — the <body> tag, which child templates might need to override
- bodyContent — whatever tags need to go in the <body>
In addition, the _boilerplate/_partials/head-meta.twig partial that contains boilerplate tags put into the <head> is included here as well.
BOILERPLATE: base-ajax-layout.twig
If the request is an AJAX / XHR request, we want to return just the page’s {% content %}
block, without any of the web page “chrome” around it.
This is exactly what this template does:
{# -- Base layout template that all AJAX requests inherit from -- #}
{% extends "_layouts/global-variables.twig" %}
{% block htmlPage %}
{% minify %}
{# -- Primary content block -- #}
{% block content %}
<code>No content block defined.</code>
{% endblock content %}
{% endminify %}
{% endblock htmlPage %}
The base-ajax-layout.twig template has the following blocks that can be overridden by its children:
- content — the core content that is represented on the page
BOILERPLATE: base-html-layout.twig
This is the base HTML layout that all HTML requests inherit from:
{# -- Base HTML layout template that all HTML requests inherit from -- #}
{% extends craft.app.request.isAjax() and not craft.app.request.getIsPreview()
? "_boilerplate/_layouts/base-ajax-layout.twig"
: "_boilerplate/_layouts/base-web-layout.twig"
%}
{% block htmlTag %}
<html class="fonts-loaded" lang="{{ craft.app.language |slice(0,2) }}" prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb#">
{% endblock htmlTag %}
{# -- Page content that should be included in the <head> -- #}
{% block headContent %}
{# -- Any <meta> tags that should be included in the <head> #}
{% block headMeta %}
{% endblock headMeta %}
{# -- Any <link> tags that should be included in the <head> #}
{% block headLinks %}
{% endblock headLinks %}
{# -- Inline and polyfill JS #}
{% include "_boilerplate/_partials/head-js.twig" %}
{# -- Any JavaScript that should be included before </head> -- #}
{% block headJs %}
{% endblock headJs %}
{# -- Inline and critical CSS #}
<style>
[v-cloak] {display: none !important;}
{# -- Any CSS that should be included before </head> -- #}
{% block headCss %}
{% endblock headCss %}
</style>
{% include "_boilerplate/_partials/critical-css.twig" %}
{% endblock headContent %}
{# -- Page content that should be included in the <body> -- #}
{% block bodyContent %}
{# -- Page content that should be included in the <body> -- #}
{% block bodyHtml %}
{% endblock bodyHtml %}
{#-- Site-wide JavaScript --#}
{{ craft.twigpack.includeSafariNomoduleFix() }}
{{ craft.twigpack.includeJsModule("app.js", true) }}
{{ craft.twigpack.includeJsModule("styles.js", true) }}
{# -- Any JavaScript that should be included before </body> -- #}
{% block bodyJs %}
{% endblock bodyJs %}
{% endblock bodyContent %}
The base-html-layout.twig template has the following blocks that can be overridden by its children:
- headMeta — Any <meta> tags that should be included in the <head>
- headLinks — Any <link> tags that should be included in the <head>
- headJs — Any JavaScript that should be included before </head>
- headCss — Any CSS that should be included before </head>
- bodyHtml — Page content that should be included in the <body>
- bodyJs — Any JavaScript that should be included before </body>
In addition, the _boilerplate/_partials/head-js.twig & _boilerplate/_partials/critical-css.twig boilerplate partials are included here as well.
BOILERPLATE: amp-base-html-layout.twig
This is the base AMP HTML layout that all AMP HTML requests inherit from:
{# -- Base AMP HTML layout template that AMP web requests inherit from -- #}
{% extends craft.app.request.isAjax() and not craft.app.request.getIsPreview()
? "_boilerplate/_layouts/base-ajax-layout.twig"
: "_boilerplate/_layouts/base-web-layout.twig"
%}
{% do seomatic.script.container().include(false) %}
{% do craft.webperf.includeBeacon(false) %}
{% block htmlTag %}
<html ⚡ lang="{{ craft.app.language |slice(0,2) }}" class="fonts-loaded">
{% endblock htmlTag %}
{# -- Page content that should be included in the <head> -- #}
{% block headContent %}
{# -- Any <meta> tags that should be included in the <head> #}
{% block headMeta %}
{% endblock headMeta %}
{# -- Any <link> tags that should be included in the <head> #}
{% block headLinks %}
{% endblock headLinks %}
{# -- Google AMP JavaScripts #}
{% include "_boilerplate/_partials/amp-head-js.twig" %}
{# -- Any JavaScript that should be included before </head> -- #}
{% block headJs %}
{% endblock headJs %}
{# -- Boilerplate & custom AMP CSS #}
{% include "_boilerplate/_partials/amp-boilerplate-css.twig" %}
<style amp-custom>
{# -- Any CSS that should be included before </head> -- #}
{% block headCss %}
{% endblock headCss %}
</style>
{% endblock headContent %}
{# -- Page content that should be included in the <body> -- #}
{% block bodyContent %}
{# -- Page content that should be included in the <body> -- #}
{% block bodyHtml %}
{% endblock bodyHtml %}
{# -- AMP Analytics --#}
{% include "_boilerplate/_partials/amp-analytics.twig" %}
{# -- Any JavaScript that should be included before </body> -- #}
{% block bodyJs %}
{% endblock bodyJs %}
{% endblock bodyContent %}
The amp-base-html-layout.twig template has the following blocks that can be overridden by its children:
- headMeta — Any <meta> tags that should be included in the <head>
- headLinks — Any <link> tags that should be included in the <head>
- headJs — Any JavaScript that should be included before </head>
- headCss — Any CSS that should be included before </head>
- bodyHtml — Page content that should be included in the <body>
- bodyJs — Any JavaScript that should be included before </body>
N.B.: these blocks are all purposefully the same as the ones used in the base-html-layout.twig template.
In addition, the _boilerplate/_partials/amp-head-js.twig, _boilerplate/_partials/amp-boilerplate-css.twig & amp-analytics.twig boilerplate partials are included here as well.
PROJECT: generic-page-layout.twig
This is a generic page layout that I’ve found suits most of the projects I build, and my other templates extends it.
For similar pages, I can even extend this layout to get everything it offers, plus what I need for another subset of pages. For example, see the generic-page-layout.twig below.
However, if I have other pages that require radically different layouts, I’ll just create another layout template that extends _boilerplate/_layouts/base-html-layout.twig and away we go!
{# -- Layout template for HTML pages -- #}
{% extends "_boilerplate/_layouts/base-html-layout.twig" %}
{# -- Any <meta> tags that should be included in the <head> #}
{% block headMeta %}
{% endblock headMeta %}
{# -- Any <link> tags that should be included in the <head> #}
{% block headLinks %}
{% endblock headLinks %}
{# -- Any CSS that should be included before </head> -- #}
{% block headCss %}
{% include "_inline-css/site-fonts.css" %}
{% endblock headCss %}
{# -- Page body -- #}
{% block bodyHtml %}
<div id="page-container" class="overflow-hidden leading-tight">
<confetti></confetti>
<div id="content-container" class="bg-repeat header-background">
{# -- Info header, including _navbar.twig -- #}
{% include "_partials/info-header.twig" %}
<main>
<div class="container mx-auto pb-8">
{# -- Primary content block -- #}
{% block content %}
{% endblock %}
</div>
</main>
</div>
{# -- Content that appears below the primary content block -- #}
{% block subcontent %}
{% endblock %}
{# -- Info footer -- #}
{% include "_partials/info-footer.twig" %}
{# -- HTML Footer -- #}
{% include "_partials/global-footer.twig" %}
</div>
{% endblock bodyHtml %}
The generic-page-layout.twig template has the following blocks that can be overridden by its children:
- content — Primary content block
- subContent — Content that appears below the primary content block
Markup in the subContent block will appear on web pages, but not on pages loaded in via AJAX / XHR.
In addition, it includes a few partials for the header, footer, etc., but you can have it do whatever makes the most sense to you.
PROJECT: error-page-layout.twig
Here we further extends the generic-page-layout.twig with another layout template that’s specifically intended for error pages.
Because we have a number of different error pages that display different content, but have the same basic layout, this is the perfect opportunity to consolidate them in another layout template.
Instead of replicating the content for each error page, we can have the error pages extends error-page-layout.twig and have very lightweight error pages.
The same idea of an inheritance chain can be used in similar situations.
{# -- Layout template for error pages -- #}
{% extends "_layouts/generic-page-layout.twig" %}
{% block content %}
{% endblock %}
{% block subcontent %}
<section>
<div class="container mx-auto py-8">
<div class="text-center p-8 mb-8">
<h1 class="font-mono italic font-bold text-5xl pt-4">
{{ entry.errorHeadline ?? 'Error' }}
</h1>
<p class="font-sans text-xl pt-4">
{{ (entry.errorText ?? 'An error has occurred.') |nl2br }}
</p>
</div>
</div>
</section>
{% endblock %}
{# -- Any JavaScript that should be included before </body> -- #}
{% block bodyJs %}
{% endblock bodyJs %}
The error-page-layout.twig template has the following blocks that can be overridden by its children:
- content — Primary content block
- subContent — Content that appears below the primary content block
Markup in the subContent block will appear on web pages, but not on pages loaded in via AJAX / XHR.
PROJECT: amp-generic-page-layout.twig
This is the Google AMP generic page template, which mirrors the blocks and methodology from the generic-page-layout.twig template, but is separated out to allow for the unique tags that Google AMP requires:
{# -- Layout template for AMP HTML pages -- #}
{% extends "_boilerplate/_layouts/amp-base-html-layout.twig" %}
{# -- Any <meta> tags that should be included in the <head> #}
{% block headMeta %}
{% endblock headMeta %}
{# -- Any <link> tags that should be included in the <head> #}
{% block headLinks %}
{% endblock headLinks %}
{# -- Any JavaScript that should be included before </head> -- #}
{% block headJs %}
{% endblock headJs %}
{# -- Any CSS that should be included before </head> -- #}
{% block headCss %}
{% include "_partials/amp-inline-css.css" %}
{% include "_inline-css/site-fonts.css" %}
{% endblock %}
{# -- Page body -- #}
{% block bodyHtml %}
{% include "_partials/amp-navbar.twig" %}
<div id="page-container" class="overflow-hidden leading-tight">
<div id="content-container" class="bg-repeat header-background">
{# -- Info header, including _navbar.twig -- #}
{% include "_partials/amp-info-header.twig" %}
<main>
<div class="container mx-auto pb-8">
{# -- Primary content block -- #}
{% block content %}
{% endblock %}
</div>
</main>
</div>
{# -- Content that appears below the primary content block -- #}
{% block subcontent %}
{% endblock %}
{# -- Info footer -- #}
{% include "_partials/amp-info-footer.twig" %}
{# -- HTML Footer -- #}
{% include "_partials/global-footer.twig" %}
</div>
{% endblock bodyHtml %}
The amp-generic-page-layout.twig template has the following blocks that can be overridden by its children:
- content — Primary content block
- subContent — Content that appears below the primary content block
Markup in the subContent block will appear on web pages, but not on pages loaded in via AJAX / XHR.
devMode.fm page: a real world example
So how does this all look with a real world example? Why, I’m glad you asked! Let’s have a look at the devMode.fm home page, which extends generic-page-layout.twig:
{% extends "_layouts/generic-page-layout.twig" %}
{% set includeAudioMeta = false %}
{% block headLinks %}
{{ parent() }}
<link rel="amphtml" href="{{ siteUrl('/amp') }}">
{% endblock headLinks %}
{% block content %}
{% include "_partials/_meta-schema-radio-series.twig" with {
"showInfo": showInfo,
} only %}
<section>
<div>
{% for episode in craft.entries.section("episodes").limit(1).all() %}
<div class="flex flex-wrap">
{% include "episodes/_partials/_display_episode.twig" with {
"episode": episode,
"showInfo": showInfo,
"includeAudioMeta": includeAudioMeta,
"autoPlay": false,
} only %}
</div>
{% endfor %}
</div>
</section>
{% endblock %}
{% block subcontent %}
{% include "episodes/_partials/_display_recent_episodes.twig" with {
"showInfo": showInfo,
} only %}
{% endblock %}
{# -- Any JavaScript that should be included before </body> -- #}
{% block bodyJs %}
{{ craft.twigpack.includeJsModule("player.js", true) }}
{{ craft.twigpack.includeJsModule("episodes.js", true) }}
{% endblock bodyJs %}
You can compare this to the devMode.fm rendered home page.
The index.twig template overrides just four blocks:
- headLinks — Here we add a link to point browsers at the Google AMP version of this page
- content — The content of this page, in this case the current episode summary & audio player
- subContent — The episodes listing component, displayed under the content
- bodyJs — Adds some JavaScript to handle the player & episodes listing to the page, courtesy of Twigpack
You can see that this makes the actual templates that we write pretty clean. And if this page was ever requested via AJAX / XHR, it’d return just the content block.
The Google AMP version of the homepage template is very similar:
{% extends "_layouts/amp-generic-page-layout.twig" %}
{% if entry is not defined %}
{% set entry = craft.entries({
"uri": " __home__",
}).one() %}
{% endif %}
{% do seomatic.helper.loadMetadataForUri(entry.uri) %}
{% do seomatic.script.container().include(false) %}
{% block headCss %}
{{ parent() }}
{{ craft.twigpack.includeFile("@webroot/dist/criticalcss/amp_index_critical.min.css") }}
{% endblock headCss %}
{% block content %}
<section>
<div>
{% for episode in craft.entries.section("episodes").limit(1).all() %}
<div class="flex flex-wrap">
{% include "episodes/_partials/_amp_display_episode.twig" with {
"episode": episode,
"showInfo": showInfo,
} only %}
</div>
{% endfor %}
</div>
</section>
{% endblock %}
{% block subcontent %}
{% include "episodes/_partials/_amp_display_recent_episodes.twig" with {
"showInfo": showInfo,
} only %}
{% endblock %}
{% block bodyJs %}
{% endblock bodyJs %}
It’s explicitly loading the appropriate entry, because it won’t be auto-injected for us by Craft, and then it loads the appropriate metadata for the route via seomatic.helper.loadMetadataForUri() and excludes all scripts via seomatic.script.container().include(false) because Google AMP doesn’t allow for them.
It’s also using Twigpack to include the full CSS for the page inline (as per Google AMP spec) but other than that… it’s the same as the regular web page example.
All about that Bass
While you certainly could just start using my boilerplate, odds are good you’ll want to customize some things to suit your tastes.
That’s totally fine. What’s important is the structure and methodology, not the specific implementation details.
The point of a modularized system like this is that if you wanted to add, say, a way to output the same content in JSON format, you could. Just slap in another layout in the right place, and away you go.
Enjoy the obligatory “All About That Bass” and have an excellent day!
Links:
Further Reading
If you want to be notified about new articles, follow nystudio107 on Twitter.
Copyright ©2020 nystudio107. Designed by nystudio107
Posted on March 23, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.