Home β€Ί Blog β€Ί Jekyll Theme Architecture: Best Practices for Clean & Scalable Code
Tutorial

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.

Jekyll Theme Architecture: Best Practices for Clean & Scalable Code

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.

Share LinkedIn