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
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 numberspadding: 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.

Share LinkedIn