Pug Template Inheritance and Includes: An Architecture Note
An architecture note on designing Pug template inheritance with extends and block directives, constraining include paths, and maintaining cache and escaping discipline.
20 Oct 2025, 11:30 UTC

Problem Statement
Server-rendered Node.js applications often rely on Pug for HTML generation. Two features shape the maintainability of a Pug codebase: template inheritance through extends and block, and file composition through include. Without an explicit architecture, these features can introduce duplicated layout logic, path traversal exposure, stale caches, and cross-site scripting (XSS) risks. This note defines the minimal design that satisfies functional, security, and operational requirements for a production Pug setup.
Requirements
- Centralized layout – A single master template owns the document skeleton (doctype, head, navigation, footer).
- Override capability – Child templates replace or append to blocks defined in the master.
- Constrained includes –
includedirectives resolve only within an approved directory tree. - Data isolation – Templates receive only an explicit
localsobject; engine internals stay hidden. - Deterministic caching – Compiled templates are reused, but the cache invalidates when source files change.
- Escaping discipline – Automatic escaping is the default; unescaped interpolation is audited.
Minimal Suitable Design
Directory Layout
Separate layouts, pages, and partials so that inheritance and composition targets are unambiguous:
templates/
├─ layouts/
│ └─ base.pug
├─ pages/
│ ├─ home.pug
│ └─ about.pug
└─ partials/
└─ nav.pugMaster Layout and Child Override
The master layout defines blocks. Child templates extend it and override only the blocks they need:
// templates/layouts/base.pug
doctype html
html
head
title #{title}
body
block header
include ../partials/nav.pug
block content
block footer
p © #{new Date().getFullYear()}// templates/pages/home.pug
extends ../layouts/base.pug
block content
h1 Welcome
p This is the home page.Because include paths are relative to the calling template, the navigation partial in the layout resolves from templates/layouts/ to templates/partials/. When a child template is rendered, Pug merges the block overrides into the inherited layout at compile time.
Include Path Whitelisting
Pug resolves include paths relative to the template that contains the directive. To prevent directory traversal, resolve and validate every include target against an allowlist before compilation. A conservative approach is to normalize the resolved path and confirm it starts with an approved root:
const path = require('path');
const fs = require('fs');
const pug = require('pug');
const templateRoot = path.resolve(__dirname, 'templates');
const allowedIncludeRoots = [
path.join(templateRoot, 'partials'),
path.join(templateRoot, 'layouts')
];
function isAllowedInclude(resolvedPath) {
return allowedIncludeRoots.some(root => resolvedPath.startsWith(root + path.sep));
}
function safeCompile(templatePath, options) {
const absolute = path.resolve(templateRoot, templatePath);
if (!absolute.startsWith(templateRoot + path.sep)) {
throw new Error('Template path outside allowed root');
}
// Validate any explicit include options here before compilation
return pug.compileFile(absolute, { filename: absolute, ...options });
}This example validates the entry template path. If your application builds include paths dynamically, validate each resolved path with the same allowlist logic before it reaches Pug. Do not assume that framework view settings alone restrict include traversal; verify the behavior against your Pug version.
Compile-Time Caching
Parsing Pug templates on every request wastes CPU. Cache the compiled function and key the cache by file path and modification time so that edits invalidate the entry:
const cache = new Map();
function getTemplate(templatePath) {
const absolute = path.resolve(templateRoot, templatePath);
const mtime = fs.statSync(absolute).mtimeMs;
const key = `${absolute}:${mtime}`;
if (!cache.has(key)) {
const compiled = pug.compileFile(absolute, { filename: absolute });
cache.set(key, compiled);
}
return cache.get(key);
}
// Usage in a request handler
const renderHome = getTemplate('pages/home.pug');
res.send(renderHome({ title: 'Home' }));In a multi-process deployment, each worker maintains its own cache. If templates are deployed as immutable artifacts, the mtime check is unnecessary and the cache can be permanent.
Trust and Data Boundaries
- Locals only – Pass data through the object given to the compiled template function. Never expose
req,res, or database clients directly to templates. - No template source exposure – The rendering boundary returns an HTML string. Do not expose compiled functions, ASTs, or template source to clients.
- Escaping rules – Interpolation with
#{variable}is escaped by default. Use!{variable}only when the value is already sanitized HTML from a trusted source. Audit every unescaped interpolation.
Operational Checks
Inheritance Verification
- Create a parent layout with an empty
block content. - Create a child that extends the layout and fills the block.
- Render the child and confirm the output contains the parent skeleton plus the child content.
Include Boundary Verification
- Attempt to include a file outside the allowed roots, such as
../../secret.txt. - Confirm the application rejects the path before Pug compiles it, or that Pug throws an error.
- If the behavior differs from expectations, review the Pug version documentation for
includeresolution rules.
Cache Invalidation Check
- Render a template twice and confirm the second call reuses the cached function.
- Modify the template file so its
mtimeMschanges. - Render again and confirm the new content appears.
Escaping Verification
- Render a template with
#{value}wherevaluecontains HTML tags. - Confirm the output escapes the tags.
- Render the same template with
!{value}and confirm raw HTML appears only when explicitly supplied.
Failure Modes
- Directory traversal – An include path that escapes the template root can read sensitive files. Mitigation: validate resolved paths against an allowlist before compilation.
- Stale cache – If the cache key ignores modification time, edited templates may not appear. Mitigation: key by
mtimeMsor use immutable build artifacts. - Unescaped interpolation –
!{}on user-controlled data enables XSS. Mitigation: restrict unescaped interpolation to vetted, pre-escaped content. - Untrusted template compilation – Compiling templates from user input can expose the AST and runtime to malicious code. Mitigation: never compile untrusted sources; use a sandbox or a restricted template language if user-supplied markup is required.
Conditions That Would Alter the Design
- User-supplied templates – If end users upload templates, do not compile them with the standard Pug compiler. Move to a sandboxed renderer or a pre-approved block system.
- Dynamic includes from user input – When the included file depends on request data, replace dynamic
includewith a lookup table that maps safe keys to fixed paths. - Large template sets – For thousands of templates, add a build step that compiles all templates ahead of time and bundles them, eliminating runtime file I/O.
- Multi-tenant layouts – If tenants need isolated layouts, use separate subdirectories per tenant and resolve the template root per request.
Conclusion
Centralizing document structure with extends and block, constraining include resolution to an allowlisted tree, passing data only through locals, and caching compiled templates with deterministic invalidation produces a maintainable Pug architecture. Because include resolution and option names can vary across Pug versions, verify the exact boundary behavior against the version deployed in your environment before relying on it in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.