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
last_modified_at: YYYY-MM-DD # Optional — for SEO freshness
image: /assets/images/blog/filename.webp # Optional — OG + post header
author: # Optional — falls back to site.author
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.