How to Convert an HTML Template to a Jekyll Theme
A step-by-step guide to converting a static HTML template into a reusable Jekyll theme β with layouts, includes, front matter, and Sass integration.
You have an HTML template you like β clean design, solid structure, good typography β but you want to run it as a Jekyll site so you can write posts in Markdown, add new pages without duplicating code, and deploy for free on GitHub Pages. Converting a static HTML template to a Jekyll theme is one of the most practical Jekyll skills you can develop, and it is far more methodical than it is difficult.
The fundamental idea is simple: HTML templates repeat themselves. Every page has the same <header>, the same <footer>, the same <nav>. Jekyll lets you write those once and reuse them everywhere via layouts and includes. This guide walks through the full conversion process β from a multi-page HTML template to a functioning Jekyll site β covering the common obstacles and their solutions.
What you are starting with
A typical purchased or downloaded HTML template contains a set of .html files, a css/ folder, a js/ folder, and an images/ or assets/ folder. Each page is a complete, standalone document. If you want to change the footer, you edit every .html file. Jekyll eliminates that entirely.
Before you touch anything, look through the HTML files and answer these questions:
What is repeated on every page? That becomes your default layout β the outer shell containing <html>, <head>, <header>, <footer>, and <script> tags. What is unique per page? That becomes the {{ content }} variable. Which HTML fragments appear on multiple but not all pages? Navigation dropdowns, sidebars, promotional banners β these become includes.
Once you have those three categories identified, the conversion is mostly mechanical.
Set up the Jekyll project structure
Jekyll expects a specific directory layout:
my-site/
βββ _config.yml # Site configuration
βββ _layouts/ # Page templates
βββ _includes/ # Reusable HTML fragments
βββ _sass/ # Sass partials
βββ assets/
β βββ css/
β βββ js/
β βββ images/
βββ _posts/ # Blog posts (dated Markdown files)
βββ index.md # Homepage
Start by creating this structure. Copy your templateβs images and JavaScript files into assets/images/ and assets/js/. The CSS will be handled separately via Sass.
Create the default layout
Open your templateβs homepage (index.html) and split it into two parts: everything that wraps the page (from <!DOCTYPE html> to the closing tags, minus the main content area), and the unique content in the middle.
The wrapper becomes _layouts/default.html. Replace the main content area with Jekyllβs {{ content }} variable:
<!DOCTYPE html>
<html lang="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 }}</title>
<meta name="description" content="{{ page.description | default: site.description }}">
{% seo %}
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
</head>
<body class="{{ page.body_class }}">
{% include header.html %}
<main class="main-content">
{{ content }}
</main>
{% include footer.html %}
<script src="{{ '/assets/js/main.js' | relative_url }}" defer></script>
</body>
</html>
Notice the changes from your original HTML: the hard-coded title becomes {{ page.title }} | {{ site.title }}, driven by each pageβs front matter and your _config.yml. The description becomes a variable with a fallback. Asset paths use Jekyllβs relative_url filter β this is critical for sites hosted at a subdirectory URL like yourusername.github.io/my-site/. Without relative_url, links break on project sites.
The {% seo %} tag comes from the jekyll-seo-tag plugin, which automatically generates correct <meta> tags for Open Graph, Twitter Cards, and canonical URLs. Add it to your Gemfile:
gem "jekyll-seo-tag"
Extract includes
Pull the navigation and footer out of the layout into separate include files.
_includes/header.html β your navigation bar:
<header class="site-header">
<div class="container">
<a href="{{ '/' | relative_url }}" class="site-logo">{{ site.title }}</a>
<nav class="site-nav" aria-label="Main">
{% for item in site.data.navigation %}
<a href="{{ item.url | relative_url }}"
{% if page.url == item.url %}aria-current="page"{% endif %}>
{{ item.title }}
</a>
{% endfor %}
</nav>
</div>
</header>
Instead of hard-coding the nav links in HTML, pull them from a data file. Create _data/navigation.yml:
- title: Home
url: /
- title: Blog
url: /blog/
- title: Themes
url: /themes/
- title: About
url: /about/
Now adding or removing a nav item means editing one YAML file rather than every HTML file.
_includes/footer.html β your footer:
<footer class="site-footer">
<div class="container">
<p>© {{ 'now' | date: "%Y" }} {{ site.title }}.
Built with <a href="https://jekyllrb.com">Jekyll</a>.</p>
{% if site.social %}
<div class="social-links">
{% if site.social.twitter %}
<a href="https://twitter.com/{{ site.social.twitter }}" rel="noopener">Twitter</a>
{% endif %}
{% if site.social.github %}
<a href="https://github.com/{{ site.social.github }}" rel="noopener">GitHub</a>
{% endif %}
</div>
{% endif %}
</div>
</footer>
The {{ 'now' | date: "%Y" }} trick keeps the copyright year current automatically β it outputs the year of the last build, so you never have to update it manually again.
Create specific layouts
Your default layout handles the outer shell. Create additional layouts that inherit from it for different page types.
_layouts/post.html for blog posts:
---
layout: default
---
<article class="post">
<header class="post-header">
<h1>{{ page.title }}</h1>
<div class="post-meta">
<time datetime="{{ page.date | date_to_xmlschema }}">
{{ page.date | date: "%B %-d, %Y" }}
</time>
{% if page.author %}<span> Β· {{ page.author }}</span>{% endif %}
{% if page.category %}<span> Β· {{ page.category }}</span>{% endif %}
</div>
{% if page.image %}
<img src="{{ page.image | relative_url }}" alt="{{ page.title }}" class="post-image">
{% endif %}
</header>
<div class="post-content">
{{ content }}
</div>
{% if page.tags.size > 0 %}
<div class="post-tags">
{% for tag in page.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</article>
_layouts/page.html for static pages:
---
layout: default
---
<div class="page">
<header class="page-header">
<h1>{{ page.title }}</h1>
{% if page.description %}<p class="page-lead">{{ page.description }}</p>{% endif %}
</header>
<div class="page-content">
{{ content }}
</div>
</div>
Convert your CSS to Sass
Move your CSS into Jekyllβs Sass pipeline for better organisation and automatic compilation.
Create assets/css/main.scss with a front matter block (the two dashed lines tell Jekyll to process this file):
---
---
@import "variables";
@import "base";
@import "layout";
@import "typography";
@import "components";
@import "syntax-highlighting";
Then create _sass/ and split your CSS into logical partials. A sensible split for most templates:
_sass/_variables.scss β colours, fonts, spacing units as Sass variables. Change a brand colour here and it updates everywhere.
_sass/_base.scss β CSS reset, base element styles (body, a, img, headings without classes).
_sass/_layout.scss β containers, grids, page structure, header, footer, sidebar.
_sass/_typography.scss β prose styles for .post-content or .page-content β paragraph spacing, blockquote styling, list formatting.
_sass/_components.scss β buttons, cards, badges, form elements, navigation.
_sass/_syntax-highlighting.scss β code block colours. Jekyll uses Rouge for syntax highlighting; the default themeβs CSS can be generated with rougify style github > _sass/_syntax-highlighting.scss.
Add to _config.yml:
sass:
sass_dir: _sass
style: compressed
Convert your HTML pages to Markdown
Each page in your HTML template becomes either a Markdown file with front matter or a minimal HTML file that uses a layout.
For content-heavy pages, Markdown is cleaner. For complex pages with lots of custom HTML (like a homepage with hero sections and feature grids), keep them as HTML but add front matter:
Simple about page:
---
layout: page
title: About
description: Learn about our team and mission.
permalink: /about/
---
We are a team of designers and developers who love Jekyll.
Complex homepage (stays as HTML):
---
layout: default
title: Home
---
<section class="hero">
<h1>Beautiful Jekyll Themes</h1>
<p>Discover, download, and deploy.</p>
<a href="/themes/" class="btn btn-primary">Browse Themes</a>
</section>
<section class="features">
<!-- ... your feature grid HTML ... -->
</section>
Handle asset paths correctly
This is the most common conversion problem. In a flat HTML template, asset paths are relative: href="css/style.css", src="../images/logo.png". Jekyll sites can be hosted at a subdirectory (/my-site/), which breaks relative paths.
Always use the relative_url filter for all asset links:
<!-- Broken on project sites -->
<link rel="stylesheet" href="/assets/css/main.css">
<img src="/assets/images/logo.png" alt="Logo">
<!-- Works everywhere -->
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
<img src="{{ '/assets/images/logo.png' | relative_url }}" alt="Logo">
Set baseurl in _config.yml if your site lives at a subdirectory:
baseurl: "/my-site" # For yourusername.github.io/my-site/
# Or leave empty for root-level sites:
baseurl: ""
Configure _config.yml
At minimum, your configuration file needs:
title: "My Site"
description: "A description for SEO and the site footer."
url: "https://yourdomain.com"
baseurl: ""
# Plugins
plugins:
- jekyll-seo-tag
- jekyll-sitemap
- jekyll-feed
# Build settings
markdown: kramdown
highlighter: rouge
# Sass
sass:
style: compressed
# Exclude development files from the build
exclude:
- Gemfile
- Gemfile.lock
- node_modules/
- vendor/
- "*.gemspec"
Test the conversion
Run Jekyll locally:
bundle exec jekyll serve --livereload
Open http://localhost:4000 and methodically check:
Layout β does the header and footer appear on every page? Do changes to _includes/header.html propagate everywhere?
CSS β open the browserβs Network tab. Do stylesheet requests return 200 OK, or are they 404? Check the path is correct relative to the site root.
Images β are all images loading? Template images that used relative paths (../images/...) may need updating.
Navigation β click every nav link. Are active states applying correctly? Are there any 404s?
Blog posts β create a test post in _posts/ with the naming pattern YYYY-MM-DD-title.md. Does it render with the post layout?
Mobile β resize to 375px. Does the layout hold?
Common problems and solutions
404 on CSS β asset path is wrong. Check whether the baseurl in _config.yml matches how the site is served. Try relative_url on every asset reference.
Layout not applying β missing or misspelled layout: in front matter. Check that the layout file name matches exactly.
Liquid errors in HTML comments β Jekyll processes {%%} and {{}} syntax everywhere, including inside HTML comments and <script> tags. If your template JS uses double curly braces for template literals, wrap those sections in `` blocks.
Nav active state not working β compare page.url carefully. Jekyllβs page.url includes a trailing slash for directory-style permalinks: /about/ not /about. Your comparison needs to match.
Images not loading β check whether the original template used absolute paths from the root or relative paths. Update to use relative_url consistently.
Once the conversion is complete, you have a Jekyll site that is dramatically easier to maintain than the original HTML template β add a page with a few lines of Markdown, change the nav in one file, update the footer once and have it reflected everywhere. From here, browse Jekyll themes on JekyllHub to see how polished themes structure their layouts and includes.
Converting JavaScript to work with Jekyll
Many HTML templates include JavaScript that manipulates URLs, references hard-coded paths, or initialises components that assume specific DOM structure. Several patterns come up repeatedly.
Mobile navigation toggle: Your template probably has a hamburger menu script. It likely works fine in Jekyll because it only touches the DOM, not URLs. But if it references window.location for active state detection, update it to compare against Jekyllβs URL structure:
// Hard-coded URL check (fragile)
if (window.location.pathname === '/about.html') { ... }
// Better: use data attributes from Jekyll
const currentPath = document.body.dataset.url; // Set via front matter
Set the body attribute in your layout:
<body data-url="{{ page.url }}">
Anchor links that break with baseurl: JavaScript that builds URLs by string concatenation fails when baseurl is non-empty. Always build URLs from the pageβs current origin rather than hard-coding:
// Fragile β breaks with non-root baseurl
fetch('/api/search.json');
// Correct β reads baseurl from the page
const baseurl = document.querySelector('meta[name="baseurl"]').content;
fetch(baseurl + '/search.json');
Add the baseurl meta tag to your layout:
<meta name="baseurl" content="{{ site.baseurl }}">
Creating the blog listing page
Your HTML template likely has a dedicated blog or news page. In Jekyll, this becomes a page that loops over site.posts:
---
layout: default
title: Blog
permalink: /blog/
---
<div class="blog-page">
<header class="page-header">
<h1>Blog</h1>
</header>
<div class="post-grid">
{% for post in site.posts %}
<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">
</a>
{% endif %}
<div class="post-card__content">
{% if post.category %}
<span class="post-card__category">{{ post.category }}</span>
{% endif %}
<h2 class="post-card__title">
<a href="{{ post.url }}">{{ post.title }}</a>
</h2>
<time datetime="{{ post.date | date_to_xmlschema }}">
{{ post.date | date: "%B %-d, %Y" }}
</time>
{% if post.description %}
<p class="post-card__excerpt">{{ post.description }}</p>
{% endif %}
</div>
</article>
{% endfor %}
</div>
</div>
If you have pagination, add jekyll-paginate to your Gemfile and _config.yml, then replace site.posts with paginator.posts and add prev/next navigation.
Maintaining the conversion long-term
Once the conversion is complete, a few habits make maintenance much easier.
Use front matter for page-specific data rather than hard-coding it in HTML. A hero sectionβs headline, call-to-action text, and button URLs are better as front matter keys than inline strings. This lets you update them without touching template code.
Keep includes small and focused. An include that renders a post card should only render a post card. If you find an include growing beyond 50β60 lines, it is doing too much β split it.
Document non-obvious template logic with Liquid comments. Future you will not remember why a particular filter chain was constructed the way it was:
{% comment %}
Sort posts by date, filter to this category, limit to 3.
Using where_exp because where does not support method calls.
{% endcomment %}
{% assign related = site.posts
| where_exp: "post", "post.category == page.category and post.url != page.url"
| sort: "date" | reverse
| limit: 3 %}
Run jekyll build --drafts regularly in development to catch template errors in draft posts before they go live. Draft posts use your production layouts but are not published, so they are a safe place to find problems early.
Every HTML template conversion is slightly different β some have more complex JavaScript, some have unusual CSS architectures, some use template engines that need to be removed. But the core process is always the same: identify repetition, extract it into layouts and includes, replace hard-coded values with Jekyll variables, and move CSS into the Sass pipeline. Once you have done one conversion, the next one takes half the time. Browse JekyllHub themes to see how the finished product should look β polished Jekyll themes are the end goal of every conversion.
The payoff for the conversion effort is significant: a site that is dramatically easier to maintain, where adding a new page is a few lines of Markdown, changing the footer takes seconds, and the build system catches errors before they go live.