How to Build a Jekyll Theme from Scratch
A complete guide to building your own Jekyll theme β layouts, includes, Sass, front matter defaults, gem packaging, and what makes a theme production-ready.
Building a Jekyll theme from scratch is one of the fastest ways to develop a deep understanding of how Jekyll works β and a marketable skill if you plan to publish or sell themes. Unlike WordPress themes, which require PHP knowledge and a specific hook-based architecture, Jekyll themes are clean collections of HTML layouts, Liquid templates, Sass partials, and a little configuration. If you understand HTML and CSS, you are most of the way there.
This guide covers the complete process: scaffolding the theme, designing a layout hierarchy, building reusable includes, organising Sass, writing front matter defaults, and packaging everything as a distributable gem. By the end, you will have a production-ready theme you can install with a single line in a Gemfile.
What a Jekyll theme actually is
A gem-based Jekyll theme is a Ruby gem that happens to contain Jekyll files. When a user adds your theme to their Gemfile and runs bundle install, your files are installed into their systemβs gem cache. Jekyll merges your theme files with their site files β their files always win. This means users can override any layout, include, or stylesheet simply by creating a file with the same name in their project.
The files a theme can contain:
_layouts/β page templates that wrap content_includes/β reusable HTML fragments_sass/β Sass partials (compiled into the userβs CSS)assets/β static files (fonts, icons, default JavaScript)
You cannot ship _posts/, _config.yml, or _data/ as part of the gem itself β those belong to the userβs site. You can ship example files alongside the gem (in a starter repository), but not inside the gem.
Scaffold the theme
Jekyll includes a built-in scaffolding command:
gem install jekyll bundler
jekyll new-theme my-theme-name
cd my-theme-name
This generates the directory structure, a .gemspec file, a LICENSE.txt, and a README.md. The generated layouts are minimal stubs β you will replace their content. The key output is the .gemspec, which defines your gemβs metadata and dependencies.
Design the layout hierarchy
A layout can declare a parent layout using a layout: key in its own front matter. This creates inheritance chains: post.html inherits from default.html, which provides the full HTML document shell. The content of the child layout is inserted at {{ content }} in the parent.
_layouts/default.html is the outermost wrapper β the complete HTML document:
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: 'en' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title | escape }}</title>
<meta name="description" content="{{ page.description | default: site.description | escape }}">
{% seo %}
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
{% feed_meta %}
{% if page.canonical_url %}
<link rel="canonical" href="{{ page.canonical_url }}">
{% endif %}
</head>
<body class="{% if page.layout %}layout--{{ page.layout }}{% endif %} {% if page.body_class %}{{ page.body_class }}{% endif %}">
{% include header.html %}
<main id="main-content" class="page-content" aria-label="Content">
<div class="container">
{{ content }}
</div>
</main>
{% include footer.html %}
<script src="{{ '/assets/js/main.js' | relative_url }}" defer></script>
</body>
</html>
Several details here are worth explaining. The body class includes layout--{{ page.layout }} β this gives you CSS hooks for layout-specific styling without JavaScript. aria-label="Content" on <main> improves screen reader navigation. {% feed_meta %} outputs the RSS <link> tag when jekyll-feed is installed. The {% seo %} tag from jekyll-seo-tag outputs canonical URLs, Open Graph, and Twitter Card meta tags β one tag replacing 15 lines of meta.
_layouts/post.html for blog posts:
---
layout: default
---
<article class="post" itemscope itemtype="http://schema.org/BlogPosting">
<header class="post-header">
<h1 class="post-title" itemprop="name headline">{{ page.title | escape }}</h1>
<div class="post-meta">
<time class="dt-published" datetime="{{ page.date | date_to_xmlschema }}" itemprop="datePublished">
{{ page.date | date: "%B %-d, %Y" }}
</time>
{% assign author_obj = site.authors | where: "name", page.author | first %}
{% if author_obj %}
<span itemprop="author" itemscope itemtype="http://schema.org/Person">
<a href="{{ author_obj.url }}">
<span itemprop="name">{{ page.author }}</span>
</a>
</span>
{% elsif page.author %}
<span itemprop="author">{{ page.author }}</span>
{% endif %}
</div>
{% if page.image %}
<figure class="post-thumbnail">
<img src="{{ page.image | relative_url }}" alt="{{ page.title | escape }}" itemprop="image" loading="eager">
</figure>
{% endif %}
</header>
<div class="post-content e-content" itemprop="articleBody">
{{ content }}
</div>
{% if page.tags.size > 0 %}
<footer class="post-footer">
<div class="post-tags" aria-label="Tags">
{% for tag in page.tags %}
<a href="{{ '/tag/' | append: tag | downcase | replace: ' ', '-' | append: '/' | relative_url }}" class="tag">
{{ tag }}
</a>
{% endfor %}
</div>
</footer>
{% endif %}
</article>
The Schema.org microdata attributes (itemscope, itemtype, itemprop) tell search engines what each piece of content is β useful for rich results in Google Search.
_layouts/page.html for static pages:
---
layout: default
---
<article class="page">
<header class="page-header">
<h1 class="page-title">{{ page.title | escape }}</h1>
{% if page.description %}
<p class="page-description">{{ page.description }}</p>
{% endif %}
</header>
<div class="page-content">
{{ content }}
</div>
</article>
_layouts/home.html for the homepage, which typically has a different structure than regular pages:
---
layout: default
---
{{ content }}
{% if site.posts.size > 0 %}
<section class="recent-posts">
<h2 class="section-title">Recent Posts</h2>
<div class="post-grid">
{% for post in site.posts limit: 6 %}
{% include post-card.html post=post %}
{% endfor %}
</div>
<a href="{{ '/blog/' | relative_url }}" class="btn">View all posts</a>
</section>
{% endif %}
Build the includes
Includes are the shared fragments that appear across layouts. Keep each include focused β it should do one thing.
_includes/header.html β site header and navigation:
<header class="site-header" role="banner">
<div class="container site-header__inner">
<a class="site-logo" href="{{ '/' | relative_url }}" rel="home">
{% if site.logo %}
<img src="{{ site.logo | relative_url }}" alt="{{ site.title }}" width="120" height="40">
{% else %}
{{ site.title | escape }}
{% endif %}
</a>
<nav class="site-nav" aria-label="Main navigation">
<button class="site-nav__toggle" aria-expanded="false" aria-controls="main-menu" aria-label="Toggle navigation">
<span class="hamburger-icon" aria-hidden="true"></span>
</button>
<ul class="site-nav__menu" id="main-menu" role="list">
{% for item in site.data.navigation %}
<li class="site-nav__item">
<a href="{{ item.url | relative_url }}"
class="site-nav__link{% if page.url == item.url or page.url contains item.url and item.url != '/' %} site-nav__link--active{% endif %}"
{% if page.url == item.url %}aria-current="page"{% endif %}>
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
</header>
_includes/footer.html β site footer:
<footer class="site-footer" role="contentinfo">
<div class="container site-footer__inner">
<p class="site-footer__tagline">{{ site.description | escape }}</p>
{% if site.data.footer_links %}
<nav class="site-footer__nav" aria-label="Footer navigation">
{% for link in site.data.footer_links %}
<a href="{{ link.url | relative_url }}">{{ link.title }}</a>
{% endfor %}
</nav>
{% endif %}
<p class="site-footer__copyright">
© {{ 'now' | date: "%Y" }} {{ site.title | escape }}.
Theme by <a href="https://jekyllhub.com" rel="noopener">JekyllHub</a>.
</p>
</div>
</footer>
_includes/post-card.html β reusable post card for listing pages:
<article class="post-card" aria-labelledby="post-title-{{ include.post.slug }}">
{% if include.post.image %}
<a href="{{ include.post.url | relative_url }}" class="post-card__image-link" tabindex="-1" aria-hidden="true">
<img src="{{ include.post.image | relative_url }}"
alt=""
class="post-card__image"
loading="lazy"
width="400" height="250">
</a>
{% endif %}
<div class="post-card__content">
{% if include.post.category %}
<span class="post-card__category">{{ include.post.category }}</span>
{% endif %}
<h3 class="post-card__title" id="post-title-{{ include.post.slug }}">
<a href="{{ include.post.url | relative_url }}">{{ include.post.title | escape }}</a>
</h3>
<time class="post-card__date" datetime="{{ include.post.date | date_to_xmlschema }}">
{{ include.post.date | date: "%B %-d, %Y" }}
</time>
{% if include.post.description %}
<p class="post-card__excerpt">{{ include.post.description }}</p>
{% endif %}
</div>
</article>
Organise the Sass
Split styles into logical partials under _sass/your-theme/:
_variables.scss defines design tokens as Sass variables. This file is the single source of truth for the themeβs visual identity β change a colour here and it propagates everywhere:
// Color palette
$color-primary: #2563eb !default;
$color-primary-dark: #1d4ed8 !default;
$color-text: #1f2937 !default;
$color-text-muted: #6b7280 !default;
$color-background: #ffffff !default;
$color-surface: #f9fafb !default;
$color-border: #e5e7eb !default;
// Typography
$font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !default;
$font-family-mono: 'Fira Code', Consolas, 'Courier New', monospace !default;
$font-size-base: 1rem !default;
$line-height-body: 1.7 !default;
// 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;
// Breakpoints
$bp-sm: 640px;
$bp-md: 768px;
$bp-lg: 1024px;
$bp-xl: 1280px;
The !default flag on each variable means users can override them by defining the variable before they import your Sass. This is the standard pattern for overridable theme variables.
_base.scss handles the CSS reset and default element styles. _layout.scss covers containers, grids, and structural CSS. _components.scss styles buttons, cards, tags, and form elements. _typography.scss styles prose content inside .post-content and .page-content.
The main entry point at assets/css/main.scss ties everything together:
---
---
@import "your-theme/variables";
@import "your-theme/base";
@import "your-theme/layout";
@import "your-theme/typography";
@import "your-theme/components";
@import "your-theme/syntax-highlighting";
The empty front matter block (the two triple-dashed lines) is mandatory β it tells Jekyll to process this file through the Sass compiler.
Set front matter defaults
Document the recommended _config.yml defaults in your README. Users paste this into their config to apply the correct layouts without specifying layout: in every fileβs front matter:
defaults:
- scope:
path: ""
type: "posts"
values:
layout: "post"
author: ""
read_time: true
comments: false
- scope:
path: ""
type: "pages"
values:
layout: "page"
- scope:
path: ""
type: "authors"
values:
layout: "author"
Write the gemspec
Edit the .gemspec file Jekyll generated. Every field matters for discoverability on RubyGems:
Gem::Specification.new do |spec|
spec.name = "my-jekyll-theme"
spec.version = "1.0.0"
spec.authors = ["Your Name"]
spec.email = ["you@example.com"]
spec.summary = "A clean, minimal Jekyll theme for bloggers and developers."
spec.description = "My Jekyll Theme is a fully responsive, accessible, SEO-optimised Jekyll theme with dark mode support."
spec.homepage = "https://github.com/yourname/my-jekyll-theme"
spec.license = "MIT"
spec.metadata = {
"plugin_type" => "theme",
"documentation_uri" => "https://github.com/yourname/my-jekyll-theme#readme",
"source_code_uri" => "https://github.com/yourname/my-jekyll-theme",
"bug_tracker_uri" => "https://github.com/yourname/my-jekyll-theme/issues"
}
spec.files = `git ls-files -z`.split("\x0").select do |f|
f.match(%r{^(assets|_data|_layouts|_includes|_sass|LICENSE|README)}i)
end
spec.add_runtime_dependency "jekyll", ">= 4.0", "< 6.0"
spec.add_runtime_dependency "jekyll-seo-tag", "~> 2.8"
spec.add_runtime_dependency "jekyll-feed", "~> 0.15"
spec.add_runtime_dependency "jekyll-sitemap", "~> 1.4"
end
What makes a theme production-ready
Before publishing, work through this checklist:
Responsive design β test at 375px (iPhone SE), 768px (iPad), and 1440px (desktop). Fix anything that breaks.
Accessibility β run axe DevTools or WAVE against your demo site. Fix all errors. Aim for zero WCAG AA violations: sufficient colour contrast, keyboard navigable, ARIA labels on icon-only buttons, focus visible on all interactive elements.
Performance β run Lighthouse on mobile. Below 90 is a red flag for buyers. Common fixes: lazy-load images below the fold, defer non-critical JS, inline critical CSS, use system fonts.
Dark mode β support prefers-color-scheme: dark with CSS custom properties. This is expected in 2026.
SEO β include jekyll-seo-tag and jekyll-sitemap as runtime dependencies. Document how to configure them.
Documentation β write a README that covers installation, required _config.yml settings, customisation, and known limitations. Themes with poor documentation get poor reviews regardless of design quality.
Demo site β deploy a live demo to GitHub Pages or Netlify. No serious buyer purchases a theme without seeing it live. Include a variety of content in the demo: long posts, short posts, posts with images, posts without, pages with and without sidebars.
Package and publish
Build the gem:
gem build my-jekyll-theme.gemspec
Test installation locally:
gem install my-jekyll-theme-1.0.0.gem
Create a test Jekyll site that uses the gem-installed theme and verify everything works from the installed gem rather than the local source.
Publish to RubyGems:
gem push my-jekyll-theme-1.0.0.gem
You will need a RubyGems account and to run gem signin first.
Submit to JekyllHub to reach the community of developers actively looking for Jekyll themes. Browse the existing Jekyll theme catalogue to understand what buyers want and what price points are common.
Adding dark mode support
Dark mode is expected in 2026. Adding it to your theme means supporting prefers-color-scheme with CSS custom properties and providing a toggle. The implementation detail that matters most for a theme is making the dark palette overridable by theme users.
Structure your colour variables with the !default flag so users can override before importing:
// _sass/your-theme/_variables.scss
$color-bg-dark: #0f172a !default;
$color-text-dark: #e2e8f0 !default;
Then in your dark mode block:
[data-theme="dark"] {
--bg-primary: #{$color-bg-dark};
--text-primary: #{$color-text-dark};
}
A user wanting to change the dark background can add to their siteβs _sass/custom.scss (imported before the theme):
$color-bg-dark: #1a1a2e;
@import "your-theme/variables";
Document this customisation pattern prominently in your README. It is the most important advanced customisation users will want.
Writing tests for your theme
A Jekyll theme should be tested before each release. Several tools help:
HTMLProofer validates the built HTML β internal links, image alt text, and markup validity:
bundle exec htmlproofer ./_site --disable-external --checks Links,Images
jekyll-theme-developer (your own test site): create a docs/ or test/ directory inside your theme repository that contains a minimal Jekyll site using the theme. This is what you deploy as the demo site, and it doubles as your integration test:
cd test-site
bundle exec jekyll build
bundle exec htmlproofer ./_site
Rake tasks to automate:
# Rakefile
require "html-proofer"
task :test do
sh "bundle exec jekyll build"
HTMLProofer.check_directory("./_site", {
disable_external: true,
checks: ["Links", "Images", "Scripts"]
}).run
end
Run bundle exec rake test in CI to catch regressions on every push.
Versioning and changelog
Follow Semantic Versioning (semver) for your gem: MAJOR.MINOR.PATCH. Increment PATCH for bug fixes, MINOR for new backward-compatible features, and MAJOR for breaking changes that require users to update their configuration.
Keep a CHANGELOG.md documenting what changed in each version. Users who pin your gem to a specific version need to understand what changed when they upgrade. A well-maintained changelog is a sign of a trustworthy theme and reduces support burden.
Tag each release in Git:
git tag -a v1.2.0 -m "Release v1.2.0"
git push origin v1.2.0
RubyGems and GitHub both use tags to show release history. GitHubβs Release feature (created from a tag) is particularly useful β you can write release notes in Markdown and users can subscribe to releases to get notified of updates.
Supporting Jekyll 4 and 5
At time of writing, Jekyll 5 is in development. Write your gemspec to be compatible with the current major version while excluding known-incompatible future ones:
spec.add_runtime_dependency "jekyll", ">= 4.0", "< 6.0"
Test against both Jekyll 4 and the latest Jekyll 5 pre-release in your CI matrix:
strategy:
matrix:
jekyll: ['~> 4.0', '~> 5.0']
ruby: ['3.2', '3.3']
Jekyll 5 has some breaking changes in the Liquid context and dropped deprecated filters. Testing against it early means your theme is ready when users upgrade.
After your first release
The work does not end at publication. Themes that stay relevant:
Respond to issues promptly. GitHub Issues are your primary support channel. Bugs reported and fixed within a few days build trust. Bugs that sit open for months discourage use.
Write blog posts about using your theme for specific use cases β a developer blog, a portfolio, documentation. These drive organic search traffic to your theme page.
List on multiple channels. RubyGems for gem installation, JekyllHub for discovery, GitHub Marketplace if relevant, and your own demo site with SEO. Each channel reaches a different part of the audience.
Watch for Jekyll updates. Subscribe to Jekyll releases and test your theme against new versions promptly. Being the first compatible theme for a new Jekyll release is a meaningful advantage.
Building a theme from scratch is one of the most comprehensive ways to master Jekyll. You are forced to think about every detail β layout hierarchy, include boundaries, Sass organisation, front matter API design, gem packaging, documentation β that users of existing themes never need to consider. The result is a reusable, distributable asset that can serve hundreds or thousands of sites. Browse JekyllHub to see the breadth of what the community has built, and use it as inspiration for what to build next.