Jekyll Theme Architecture: Best Practices for Clean & Scalable Code
How to structure a Jekyll theme for maintainability and scale β file organisation, Sass architecture, reusable includes, front matter conventions, and layout hierarchy.
Most Jekyll sites start as a quick setup and slowly become hard to maintain β styles scattered across files, layouts duplicating logic, includes tangled with page-specific code. This guide covers the structural decisions that keep a Jekyll theme clean as it grows.
The File Structure That Scales
A well-organised Jekyll theme separates concerns cleanly:
my-theme/
βββ _layouts/
β βββ default.html # Root wrapper β HTML shell only
β βββ page.html # Static pages
β βββ post.html # Blog posts
β βββ home.html # Homepage
β βββ archive.html # Tag/category archive pages
β
βββ _includes/
β βββ head/
β β βββ meta.html # Core meta tags
β β βββ fonts.html # Font loading
β β βββ analytics.html # Analytics (loaded conditionally)
β βββ header.html
β βββ footer.html
β βββ nav.html
β βββ post-card.html # Reusable post card component
β βββ theme-card.html # Reusable theme card component
β βββ pagination.html
β
βββ _sass/
β βββ abstracts/
β β βββ _variables.scss # Design tokens
β β βββ _mixins.scss # Reusable mixins
β β βββ _functions.scss # Sass functions
β βββ base/
β β βββ _reset.scss # CSS reset
β β βββ _typography.scss
β β βββ _base.scss # Element defaults
β βββ components/
β β βββ _buttons.scss
β β βββ _cards.scss
β β βββ _badges.scss
β β βββ _forms.scss
β βββ layouts/
β β βββ _header.scss
β β βββ _footer.scss
β β βββ _nav.scss
β β βββ _homepage.scss
β β βββ _post.scss
β β βββ _theme-detail.scss
β βββ main.scss # Import manifest only
β
βββ assets/
β βββ css/
β β βββ main.scss # Entry point (front matter triggers Jekyll processing)
β βββ js/
β β βββ main.js # Bundled JS
β β βββ search.js # Optional β search functionality
β βββ images/
β
βββ _data/
β βββ navigation.yml # Nav items
β βββ settings.yml # Theme feature flags
β
βββ _config.yml
Layout Hierarchy: Keep Nesting Shallow
Jekyll layouts nest β post.html wraps its content inside default.html. Keep this chain as short as possible:
default.html # HTML shell, head, body wrapper
βββ page.html # Simple content wrapper
βββ post.html # Post header + content + footer
βββ home.html # Homepage sections
Avoid deep nesting like default β base β page β post. Each extra layer makes debugging harder and adds cognitive overhead.
Rule: If a layout only adds {{ content }} with no surrounding markup, it probably shouldnβt be a separate layout β merge it up.
The Default Layout: HTML Shell Only
_layouts/default.html should do one thing: provide the HTML skeleton. No design decisions, no conditional content:
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: 'en' }}"
data-theme="{{ site.theme_mode | default: 'light' }}">
<head>
{% include head/meta.html %}
{% include head/fonts.html %}
{% if site.analytics.google_id %}{% include head/analytics.html %}{% endif %}
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
</head>
<body class="page--{{ page.layout | default: 'default' }}">
{% include header.html %}
<main id="main-content" class="site-main">
{{ content }}
</main>
{% include footer.html %}
<script src="{{ '/assets/js/main.js' | relative_url }}" defer></script>
</body>
</html>
Notice class="page--{{ page.layout }}" β this adds a layout-specific class to <body>, letting you write targeted CSS like .page--home .hero without specificity fights.
Sass Architecture: The 7-1 Pattern (Simplified)
The 7-1 pattern organises Sass into 7 folders with 1 main file that imports them all. For Jekyll themes, a simplified 4-folder version works better:
// assets/css/main.scss
---
---
// 1. Abstracts β no output, just tools
@import "abstracts/variables";
@import "abstracts/mixins";
// 2. Base β element-level styles
@import "base/reset";
@import "base/typography";
@import "base/base";
// 3. Components β UI building blocks
@import "components/buttons";
@import "components/cards";
@import "components/badges";
@import "components/forms";
// 4. Layouts β page section styles
@import "layouts/header";
@import "layouts/footer";
@import "layouts/nav";
@import "layouts/homepage";
@import "layouts/post";
Rule: main.scss is a manifest only β never write actual styles there.
Design Tokens in Variables
Define all values as variables. Never hard-code colours, spacing, or font sizes in component files:
// _sass/abstracts/_variables.scss
// Colour palette β raw values
$color-blue-500: #3b82f6;
$color-blue-600: #2563eb;
$color-gray-50: #f9fafb;
$color-gray-900: #111827;
// Semantic tokens β map palette to purpose
$color-primary: $color-blue-600;
$color-text: $color-gray-900;
$color-background: #ffffff;
$color-border: #e5e7eb;
// Typography
$font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-mono: 'Fira Code', Consolas, monospace;
$font-size-base: 1rem;
$font-size-sm: 0.875rem;
$font-size-lg: 1.125rem;
$line-height-base: 1.7;
// Spacing scale
$space-1: 0.25rem;
$space-2: 0.5rem;
$space-3: 0.75rem;
$space-4: 1rem;
$space-6: 1.5rem;
$space-8: 2rem;
$space-12: 3rem;
$space-16: 4rem;
// Layout
$container-width: 1100px;
$content-width: 720px;
$sidebar-width: 280px;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 16px;
// Transitions
$transition-fast: 150ms ease;
$transition-base: 250ms ease;
When a designer asks you to βmake the primary colour purpleβ, you change one line.
Reusable Includes with Parameters
Includes become powerful when they accept parameters. This prevents code duplication across layouts:
<!-- _includes/post-card.html -->
{% assign post = include.post %}
{% assign show_excerpt = include.show_excerpt | default: true %}
{% assign show_tags = include.show_tags | default: true %}
<article class="post-card">
{% if post.image %}
<a href="{{ post.url }}" class="post-card__image-link">
<img src="{{ post.image | relative_url }}"
alt="{{ post.title }}"
loading="lazy"
class="post-card__image">
</a>
{% endif %}
<div class="post-card__body">
<div class="post-card__meta">
<time datetime="{{ post.date | date_to_xmlschema }}">
{{ post.date | date: "%b %d, %Y" }}
</time>
{% if post.category %}
<span class="post-card__category">{{ post.category }}</span>
{% endif %}
</div>
<h2 class="post-card__title">
<a href="{{ post.url }}">{{ post.title }}</a>
</h2>
{% if show_excerpt %}
<p class="post-card__excerpt">
{{ post.description | default: post.excerpt | strip_html | truncatewords: 25 }}
</p>
{% endif %}
{% if show_tags and post.tags.size > 0 %}
<div class="post-card__tags">
{% for tag in post.tags limit: 3 %}
<a href="/tag/{{ tag | downcase }}/" class="tag">{{ tag }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</article>
Usage in any layout:
{% include post-card.html post=post %}
{% include post-card.html post=post show_excerpt=false %}
{% include post-card.html post=post show_tags=false show_excerpt=true %}
One include, three variations, no duplication.
Front Matter Conventions
Consistent front matter across all content files makes templates simpler:
# Posts
---
layout: post
title: "" # Required β sentence case, include primary keyword
description: "" # Required β 150-160 chars, for meta description
date: YYYY-MM-DD # Required
author: # Optional β falls back to site.author
last_modified_at: YYYY-MM-DD # Optional β for SEO freshness
image: /assets/images/blog/filename.webp # Optional β OG + post header
category: "" # Single category string
featured: false # Controls homepage carousel
tags: [] # Array of lowercase strings
toc: false # Table of contents toggle
comments: true # Comments section toggle
---
Document these conventions in your themeβs README so contributors know whatβs expected.
Feature Flags via Data Files
Instead of hardcoding feature toggles in layouts, use a data file:
# _data/settings.yml
features:
dark_mode: true
search: true
reading_time: true
copy_code: true
social_share: true
newsletter: false
comments: false
analytics:
google_id: ""
plausible_domain: ""
In your layouts:
{% if site.data.settings.features.dark_mode %}
{% include dark-mode-toggle.html %}
{% endif %}
{% if site.data.settings.features.comments and page.comments != false %}
{% include comments.html %}
{% endif %}
Users configure the theme by editing _data/settings.yml β not by hunting through layout files.
Avoiding the Most Common Architecture Mistakes
Putting styles in layouts β Never put <style> tags in layout files. All styles belong in _sass/.
God includes β An include that does 10 different things is hard to maintain. Split it into focused, single-purpose includes.
Magic numbers β padding: 37px with no explanation is a code smell. Use spacing variables and leave a comment if the value is non-obvious.
Overspecific selectors β .site-header nav ul li a:hover is fragile. .nav__link:hover is maintainable.
No mobile-first β Write your base styles for small screens, then use min-width media queries to enhance for larger screens. Retrofitting a desktop design for mobile is always harder.
Well-architected themes are easier to maintain, easier for users to customise, and faster to build on top of. Browse the best-structured open-source examples on JekyllHub β Minimal Mistakes and Chirpy are both worth studying for architecture ideas.
The includes depth problem
A common architectural mistake is building an include hierarchy that is too deep. Includes nest freely in Jekyll, and it is tempting to compose components by nesting includes inside each other:
default.html
β header.html
β nav.html
β nav-item.html
β nav-dropdown.html
Four levels deep creates real problems: debugging requires tracing through four files to find where a nav item renders, performance slightly degrades (each include has a small overhead), and the mental model becomes complex for new contributors.
A better rule: keep includes shallow. If a component naturally composes sub-components, write the subcomponent directly in the parent include rather than creating a third-level include file. Extract to a new include only when the component is genuinely reused in three or more places.
Handling multiple layout variants with one include
Instead of separate includes for βpost card (with image)β and βpost card (without image)β, a single parameterised include is cleaner. But what about fundamentally different card layouts β like a compact list item vs a full grid card?
Use a variant parameter:
<!-- _includes/post-card.html -->
{% assign variant = include.variant | default: "grid" %}
{% assign post = include.post %}
{% if variant == "list" %}
<article class="post-list-item">
<time>{{ post.date | date: "%b %d" }}</time>
<a href="{{ post.url }}">{{ post.title }}</a>
{% if post.category %}<span class="category">{{ post.category }}</span>{% endif %}
</article>
{% elsif variant == "featured" %}
<article class="post-card post-card--featured">
{% if post.image %}
<img src="{{ post.image | relative_url }}" alt="{{ post.title }}" class="post-card__image post-card__image--large">
{% endif %}
<div class="post-card__body">
<h2 class="post-card__title post-card__title--large">
<a href="{{ post.url }}">{{ post.title }}</a>
</h2>
<p class="post-card__excerpt">{{ post.description }}</p>
</div>
</article>
{% else %}
<!-- Default: grid -->
<article class="post-card">
{% if post.image %}
<img src="{{ post.image | relative_url }}" alt="{{ post.title }}" class="post-card__image" loading="lazy">
{% endif %}
<div class="post-card__body">
<h3 class="post-card__title"><a href="{{ post.url }}">{{ post.title }}</a></h3>
<p class="post-card__excerpt">{{ post.description | truncatewords: 20 }}</p>
</div>
</article>
{% endif %}
Usage:
{% include post-card.html post=featured_post variant="featured" %}
{% for post in recent_posts %}
{% include post-card.html post=post variant="grid" %}
{% endfor %}
{% for post in archive_posts %}
{% include post-card.html post=post variant="list" %}
{% endfor %}
One include file, three layouts, no duplication.
Consistent naming conventions
Naming inconsistency is a slow-building maintenance problem β you forget what a partial is called, you create duplicates with slightly different names, new contributors cannot find what they are looking for.
Choose one convention and stick to it throughout:
BEM (Block-Element-Modifier) for CSS classes: .post-card, .post-card__title, .post-card--featured. Widely understood, eliminates naming collisions.
Lowercase kebab-case for filenames: post-card.html, theme-detail.scss, dark-mode-toggle.js. Consistent across all operating systems (macOS is case-insensitive by default; Linux is not).
Descriptive, noun-first names for includes: post-card.html not card-post.html, theme-grid.html not grid-themes.html. Makes includes sort logically in your editorβs file tree.
Prefix partials with underscores in _sass/: _cards.scss, _buttons.scss. Jekyllβs Sass pipeline convention, and it signals that these files are imported rather than compiled directly.
Managing growing _config.yml
As a theme grows more configurable, _config.yml can accumulate dozens of settings that are hard to navigate. Two strategies help:
Namespace settings under a theme key:
# All theme-specific settings under one key
jekyllhub:
features:
dark_mode: true
search: true
copy_code: true
layout:
sidebar: true
toc: true
social:
twitter: jekyllhub
github: jekyllhub
Access these in Liquid as {{ site.jekyllhub.features.dark_mode }}. The namespace prevents collision with Jekyllβs own config keys and makes the themeβs config section easy to find.
Use _data/settings.yml for feature flags and keep _config.yml for Jekyllβs own settings (url, baseurl, plugins, defaults). This way _config.yml stays minimal and focused on build configuration.
Performance-aware architecture
Good architecture and good performance overlap significantly. A few architectural choices that pay performance dividends:
Never load CSS that belongs to an optional feature unconditionally. If search is a feature flag, the search stylesheet should only be included when search is enabled:
{% if site.data.settings.features.search %}
<link rel="stylesheet" href="{{ '/assets/css/search.css' | relative_url }}">
{% endif %}
Split JavaScript by feature. One large main.js that includes all features β including ones the current page does not use β is unnecessary. Load feature-specific scripts only on the pages that need them:
<!-- In _layouts/post.html -->
<script src="{{ '/assets/js/post.js' | relative_url }}" defer></script>
<!-- In _layouts/home.html -->
<script src="{{ '/assets/js/home.js' | relative_url }}" defer></script>
Use loading="lazy" on images in includes. This should be the default in every image-rendering include, with loading="eager" only for above-the-fold hero images.
Avoid site.posts loops in frequently-used includes. Looping over all posts to find something in every page header is expensive. Assign once at the layout level and pass as a parameter.
Good architecture makes performance optimisations straightforward because each responsibility is isolated. Knowing where the slow part is β because your code is well-structured β is more valuable than micro-optimising an entangled codebase.
The best Jekyll themes on JekyllHub share one quality: their code is readable. New contributors can open a layout file and understand what it does in five minutes. That readability is not an accident β it is the result of consistent naming, shallow nesting, focused includes, and a sensible Sass structure. Invest in architecture early and your theme stays maintainable as it grows.
Documentation as architecture
The final element of good Jekyll theme architecture is documentation that lives alongside the code. A well-architectured theme that is poorly documented will confuse contributors and users alike.
At minimum, document in your README: the directory structure and what each directory contains, all front matter keys and their defaults, all _config.yml settings the theme reads, how to override styles and layouts, and how to run the development environment locally.
Consider adding inline comments to complex Liquid logic β particularly any use of where_exp, group_by, or multi-step filter chains. These are often not self-explanatory to someone reading the code for the first time.
A theme that is easy to understand is a theme that gets used. The best Jekyll theme in the world does not help anyone if no one can figure out how to configure it.
Testing your theme architecture
Architecture decisions that seem clean in isolation can create problems under load β when there are many posts, many templates, and multiple contributors. A few tests worth running regularly:
Build time profiling. Run bundle exec jekyll build --profile and look at the output table. If any single template takes more than 100ms, investigate why. Common culprits: includes that loop over all posts, complex Liquid filter chains in frequently-rendered partials, or large data files loaded repeatedly.
Layout coverage check. Verify that every post type renders correctly with a simple script:
bundle exec jekyll build 2>&1 | grep -i "layout\|error\|warning"
Any layout errors surface here before they cause issues for readers.
Mobile render test. Open every layout variant (home, post, page, archive) at 375px width in browser DevTools. Overflow issues, broken grids, and illegible text are common at this width and easy to miss when developing on a desktop.
Accessibility audit. Run axe DevTools or WAVE against each layout type after a build. Most accessibility issues β missing labels, insufficient contrast, keyboard-inaccessible interactive elements β are architectural: they come from include templates that need fixes, not one-off page content.
Architecture is not a one-time decision β it is an ongoing discipline. Review it when the site grows significantly (more content types, more contributors, more traffic) and adjust rather than working around structural limitations that have accumulated.
Architecture decisions compound over time: good ones make the next change easy, poor ones make every change harder. The practices in this guide β shallow layout hierarchies, parameterised includes, SCSS organised by purpose, design tokens in variables, feature flags in data files β are not prescriptive rules but practical patterns that have been validated across hundreds of Jekyll sites. Apply them pragmatically: add structure when the complexity warrants it, not before. A well-organised simple theme is always preferable to an over-engineered complex one.
Browse Jekyll themes on JekyllHub to see different architectural approaches in practice β examining how mature themes structure their layouts, includes, and Sass is one of the fastest ways to develop good architectural instincts.