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 the fastest way to deeply understand Jekyll — and it’s more achievable than it sounds. This guide walks through building a complete, distributable theme, from the first file to a packaged gem.
What Makes a Jekyll Theme
A Jekyll theme is a set of files that define the appearance and structure of a Jekyll site:
_layouts/— page templates_includes/— reusable HTML fragments_sass/— stylesheetsassets/— static files (fonts, icons, JS)
When a user installs your theme, your files provide the defaults. Their site files override yours selectively.
Step 1: Scaffold the Theme
Use Jekyll’s built-in theme scaffold command:
jekyll new-theme my-theme-name
cd my-theme-name
This creates:
my-theme-name/
├── _includes/
├── _layouts/
│ ├── default.html
│ ├── page.html
│ └── post.html
├── _sass/
│ ├── my-theme-name/
│ │ ├── _base.scss
│ │ ├── _layout.scss
│ │ └── _syntax-highlighting.scss
│ └── my-theme-name.scss
├── assets/
│ └── main.scss
├── Gemspec
├── LICENSE.txt
└── README.md
Step 2: Design Your Layout System
Default Layout
_layouts/default.html is the outer wrapper for all pages:
<!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>
{% seo %}
<link rel="stylesheet" href="{{ '/assets/main.css' | relative_url }}">
{% feed_meta %}
</head>
<body class="{% if page.layout %}layout--{{ page.layout }}{% endif %}">
{% include header.html %}
<main 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>
Post Layout
_layouts/post.html wraps 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>
{% if page.author %}
<span itemprop="author" itemscope itemtype="http://schema.org/Person">
<span class="p-author h-card" itemprop="name">{{ page.author }}</span>
</span>
{% endif %}
{% if page.read_time %}
<span class="read-time">{{ content | number_of_words | divided_by: 200 | plus: 1 }} min read</span>
{% endif %}
</div>
{% if page.image %}
<div class="post-thumbnail">
<img src="{{ page.image | relative_url }}" alt="{{ page.title }}" loading="lazy">
</div>
{% 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">
{% for tag in page.tags %}
<a href="/tag/{{ tag | downcase | replace: ' ', '-' }}/" class="tag">{{ tag }}</a>
{% endfor %}
</div>
</footer>
{% endif %}
</article>
Page Layout
_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>
Step 3: Build the Includes
Header
_includes/header.html:
<header class="site-header">
<div class="container">
<a class="site-title" rel="author" href="{{ '/' | relative_url }}">
{{ site.title | escape }}
</a>
<nav class="site-nav" aria-label="Main navigation">
<button class="site-nav__toggle" aria-expanded="false" aria-controls="nav-menu">
<span class="sr-only">Menu</span>
<span class="hamburger"></span>
</button>
<ul class="site-nav__menu" id="nav-menu" role="list">
{% for item in site.data.navigation %}
<li>
<a href="{{ item.url | relative_url }}"
{% if page.url == item.url or page.url contains item.url and item.url != '/' %}
class="active" aria-current="page"
{% endif %}>
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
</header>
Footer
_includes/footer.html:
<footer class="site-footer">
<div class="container">
<div class="site-footer__content">
<p class="site-footer__description">
{{ site.description | escape }}
</p>
{% if site.social %}
<ul class="social-links" role="list">
{% if site.social.twitter %}
<li><a href="https://twitter.com/{{ site.social.twitter }}" aria-label="Twitter">Twitter</a></li>
{% endif %}
{% if site.social.github %}
<li><a href="https://github.com/{{ site.social.github }}" aria-label="GitHub">GitHub</a></li>
{% endif %}
</ul>
{% endif %}
</div>
<p class="site-footer__copyright">
© {{ 'now' | date: "%Y" }} {{ site.title | escape }}.
Powered by <a href="https://jekyllrb.com">Jekyll</a>.
</p>
</div>
</footer>
Step 4: Write the Sass
Organise styles into logical partials under _sass/your-theme/:
_variables.scss — design tokens:
// Colours
$color-primary: #2563eb;
$color-text: #1f2937;
$color-text-muted: #6b7280;
$color-background: #ffffff;
$color-border: #e5e7eb;
$color-card: #f9fafb;
// Typography
$font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
$font-family-mono: 'Fira Code', Consolas, 'Courier New', monospace;
$font-size-base: 1rem;
$line-height-base: 1.7;
// Spacing
$spacing-xs: 0.25rem;
$spacing-sm: 0.5rem;
$spacing-md: 1rem;
$spacing-lg: 2rem;
$spacing-xl: 4rem;
// Layout
$container-max-width: 1100px;
$content-max-width: 720px;
_base.scss — reset and base elements:
*, *::before, *::after {
box-sizing: border-box;
}
body {
font-family: $font-family-base;
font-size: $font-size-base;
line-height: $line-height-base;
color: $color-text;
background-color: $color-background;
-webkit-font-smoothing: antialiased;
}
a {
color: $color-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
img {
max-width: 100%;
height: auto;
}
code {
font-family: $font-family-mono;
font-size: 0.9em;
background: $color-card;
padding: 0.15em 0.4em;
border-radius: 4px;
}
Step 5: Define Front Matter Defaults
In your theme’s _config.yml (or document in README for users to add):
# Recommended defaults for themes using this layout
defaults:
- scope:
path: ""
type: "posts"
values:
layout: "post"
author: ""
read_time: true
comments: true
share: true
- scope:
path: ""
type: "pages"
values:
layout: "page"
Step 6: Add Navigation Data
Create _data/navigation.yml with default navigation:
- title: Home
url: /
- title: Blog
url: /blog/
- title: About
url: /about/
Step 7: Package as a Gem
Edit the .gemspec file Jekyll created:
# my-theme-name.gemspec
Gem::Specification.new do |spec|
spec.name = "my-theme-name"
spec.version = "1.0.0"
spec.authors = ["Your Name"]
spec.email = ["your@email.com"]
spec.summary = "A clean, minimal Jekyll theme for blogs."
spec.homepage = "https://github.com/username/my-theme-name"
spec.license = "MIT"
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", "< 5.0"
spec.add_runtime_dependency "jekyll-seo-tag", "~> 2.8"
spec.add_runtime_dependency "jekyll-feed", "~> 0.15"
end
Build the gem:
gem build my-theme-name.gemspec
Install locally to test:
gem install my-theme-name-1.0.0.gem
Step 8: What Makes a Theme Production-Ready
Before releasing or selling your theme, check these:
Responsive design — Test on mobile (360px), tablet (768px), and desktop (1440px).
Dark mode — Add CSS variables and a toggle. Expected in 2026.
Accessibility — Semantic HTML, ARIA labels on interactive elements, sufficient colour contrast (WCAG AA = 4.5:1 minimum).
SEO — Include jekyll-seo-tag. Document how to configure it.
Performance — Run PageSpeed Insights. Aim for 90+ on mobile.
Documentation — Write a clear README with installation, configuration, and customisation instructions. This is the most underrated part of a theme.
Demo site — A live demo is essential. No serious buyer evaluates a theme without seeing it live.
Publishing Your Theme
GitHub — Publish the source code as a public repository. Tag releases with semantic versions.
RubyGems — Run gem push my-theme-name-1.0.0.gem to list on rubygems.org.
Marketplaces — Submit to JekyllHub to reach Jekyll users actively looking for themes.
Building a theme from scratch gives you complete control over performance, accessibility, and design — and a deep understanding of how Jekyll works. Once you’ve built one, customising any other theme becomes trivial.
Browse existing Jekyll themes on JekyllHub to see what’s popular and understand what users are looking for before you build.