Jekyll SEO

How to optimize Jekyll sites for search — why its static HTML is crawler-friendly by default, plus the jekyll-seo-tag and jekyll-sitemap plugins, permalinks, collections, robots.txt, and the GitHub Pages plugin whitelist.

First published: Jun 26, 2026 · Last updated: Jul 27, 2026 · Advanced
demand #1 in Static Site Generators#31 in Platform SEO#243 in Technical SEO#339 on the site
1 evidence signal on this page

Jekyll outputs flat, static HTML — crawlers get complete content on the first fetch, with no JavaScript rendering delay. So the SEO work isn't fighting the framework; it's configuration. Install jekyll-seo-tag (title, description, canonical, Open Graph, Twitter Card, JSON-LD) and jekyll-sitemap (sitemap.xml), and set url: in _config.yml or both produce broken output. Pick clean permalinks (/:title/, not date-based) for evergreen content. Create robots.txt yourself — Jekyll won't. Watch two GitHub Pages traps: the plugin whitelist (only a fixed set runs without a GitHub Actions build) and baseurl misconfiguration on project sites, which breaks every canonical URL.

TL;DR — Jekyll emits flat static HTML at build time, so content is in the raw response on the first crawl — no Web Rendering Service, no Wave 2 delay. The SEO work is configuration, not architecture. Install jekyll-seo-tag (title, description, canonical, OG, Twitter Card, JSON-LD) and jekyll-sitemap (sitemap.xml); both require url: in _config.yml or they produce broken output. Use clean permalinks (/:title/) over date-based ones for evergreen content. Two GitHub Pages traps dominate: the plugin whitelist (GitHub builds with --safe; non-whitelisted plugins need a GitHub Actions build) and a missing baseurl on project sites, which breaks every generated canonical URL. GitHub Pages also pins specific plugin versions (3.10.0 core, older jekyll-seo-tag/jekyll-sitemap releases), not just which plugins run. Collections need output: true or they’re never rendered. Drafts, future-dated posts, and published: false documents are excluded from a normal build, and --incremental is experimental — don’t use it for production deploys. And robots.txt is not auto-generated — make it yourself.

Why Jekyll’s static output is good for SEO

Jekyll is a static site generator: it runs your Markdown and Liquid templates through a build step and emits finished HTML, once, before any request. That timing is the whole SEO advantage. Evidence for this claim Jekyll processes text and templates into static files during a build. Scope: Jekyll build architecture. Confidence: high · Verified: Jekyll documentation

Google’s pipeline is crawl → render → index, and rendering JavaScript is “a separate step” that sits in a queue — what people loosely call the “two-wave” process. Wave 1 fetches raw HTML and indexes text and links immediately; Wave 2 queues the page for the Web Rendering Service to run JavaScript, “a few seconds to weeks” later depending on crawl budget. With Jekyll, Wave 1 already contains all your content — there’s no JavaScript required to render the body. Wave 1 = Wave 2. No rendering delay, no rendering budget spent. This is the same argument that makes any static site generator the lowest-risk architecture for indexability.

The downstream benefits follow:

  • Faster TTFB. Pre-built files served from a CDN (GitHub Pages sits behind Fastly; Netlify/Vercel have their own edges) mean no database queries and no server processing — good for LCP and the rest of Core Web Vitals.
  • No required JS payload for content → better FCP and LCP than a hydrating SPA.
  • Proper HTTP status codes at the CDN layer, not client-side error handling.

But none of that changes the fundamentals: static sites still need meta tags, sitemaps, canonical URLs, structured data, and good content. Jekyll’s output is crawler-friendly; the metadata is on you.

GitHub Pages and Jekyll SEO

The most common Jekyll deployment is GitHub Pages — push to a branch with Pages enabled and GitHub auto-builds the site. That convenience comes with SEO-relevant constraints.

Project sites vs. user/org sites — the URL decision

  • username.github.io/repo-name (a project site) lives on a shared subdomain with thousands of other unrelated sites. It works fine for docs, demos, and personal projects where the URL itself doesn’t need to carry your brand.
  • A custom domain (point a CNAME at GitHub Pages) puts the site on a URL you control, with HTTPS auto-provisioned by GitHub. For a site where the domain itself matters — a business site, a blog you’re building an audience around — a custom domain from the start avoids a domain migration later.

If you expect to move to a custom domain eventually, set it up early. Any domain migration means 301 redirects (jekyll-redirect-from handles this) and a period where inbound links and any accumulated signals point at the old URL — avoidable churn if you pick the final domain up front rather than switching later. This is a migration-planning argument, not a claim that a github.io subdomain is penalized on its own.

baseurl — the #1 canonical bug on project sites

Project sites live under a subfolder (/repo-name/). If you don’t set baseurl: /repo-name in _config.yml, every canonical URL jekyll-seo-tag generates — and your internal links — will be wrong, missing the subfolder. This is the single most common canonical-URL bug on Jekyll project sites. (User/org sites served at the domain root don’t need baseurl.)

The plugin whitelist

GitHub Pages runs Jekyll with the --safe flag and only allows a fixed set of plugins. Evidence for this claim GitHub Pages builds Jekyll in safe mode and supports a documented set of plugins. Scope: GitHub Pages hosted builds. Confidence: high · Verified: GitHub Pages: Jekyll plugins The SEO-relevant whitelisted ones:

  • jekyll-seo-tag
  • jekyll-sitemap
  • jekyll-redirect-from ✓ (for 301s when you change URLs)
  • jekyll-paginate

Not whitelisted (and silently won’t run, or will error):

  • jekyll-last-modified-at — needed for accurate sitemap <lastmod> from file timestamps
  • Custom structured-data plugins
  • Anything dropped in a _plugins/ folder

It’s not just which plugins — it’s which versions

GitHub Pages doesn’t only restrict which plugins run; it pins the exact version of each one, and that build environment itself is pinned to an older Jekyll. As of the dependency list’s last update, the hosted build runs Jekyll 3.10.0 with jekyll-seo-tag 2.8.0, jekyll-sitemap 1.4.0, and jekyll-feed 0.17.0 — while jekyllrb.com’s own docs describe the current upstream release, 4.4.1. A behavior documented in a plugin’s latest README isn’t guaranteed to exist in the version GitHub Pages actually runs; check pages.github.com/versions.json for the pinned version before relying on a specific flag or output. A GitHub Actions build sidesteps this too — you control the Gemfile, so you get the versions you pin, not GitHub’s.

The GitHub Actions workaround

The fix for the whitelist isn’t “add more gems” — it’s to stop letting GitHub do the build. Run Jekyll yourself in CI (actions/jekyll-build-pages, or build locally and push _site/ to the deploy branch with peaceiris/actions-gh-pages) and the whitelist no longer applies. Now any plugin runs, and you pick the Jekyll and plugin versions instead of inheriting GitHub’s pinned set.

The jekyll-seo-tag plugin

This is the official, maintained plugin that covers most of the core metadata Jekyll doesn’t add on its own. Exactly what it emits depends on which version you have installed, your _config.yml and front matter, and whether your layout actually calls {% seo %} — GitHub Pages, for instance, pins a specific version rather than always shipping the latest release (more on that below). Check the advanced usage guide for your version’s exact output, and confirm what actually landed by grepping your own built HTML rather than assuming it’s complete.

Install:

# Gemfile
gem 'jekyll-seo-tag'
# _config.yml
plugins:
  - jekyll-seo-tag
<!-- _layouts/default.html, before </head> -->
{% seo %}

What it generates automatically:

  • <title> — page title with site name appended (Page Title | Site Name)
  • <meta name="description"> — from description: front matter or the site description
  • <link rel="canonical"> — built from site.url + page.url
  • Open Graph tags (og:title, og:description, og:url, og:site_name, og:image)
  • Twitter Card tags (twitter:card, twitter:title, twitter:description, twitter:creator, twitter:image)
  • JSON-LD structured data (BlogPosting for posts, WebSite for the home page)
  • Pagination meta (next/prev URLs)

Required _config.yml settings — without these the plugin produces broken output:

title: Your Site Title
description: Your site description
url: "https://yourdomain.com"   # CRITICAL — drives canonical URL generation
author:
  name: Patrick Stox
  twitter: patrickstox
  url: https://patrickstox.com  # author disambiguation
twitter:
  username: patrickstox
  card: summary_large_image

Per-page front matter overrides:

---
title: "Jekyll SEO Guide"
description: "How to optimize Jekyll sites for search engines."
image:
  path: /assets/jekyll-seo-og.png
  width: 1200
  height: 630
  alt: "Jekyll SEO diagram"
canonical_url: "https://example.com/jekyll-seo/"  # override if needed
robots: noindex   # per-page noindex
seo:
  type: BlogPosting          # schema.org type override
  date_modified: 2025-01-15  # dateModified override for JSON-LD
---

Suppression (when your layout already outputs its own):

{% seo title=false %}      <!-- suppress the <title> -->
{% seo canonical=false %}  <!-- suppress the canonical link -->

One gotcha worth flagging: many minimal themes (including minima) ship without jekyll-seo-tag wired in. Adding the gem to your Gemfile does nothing unless the theme’s layout actually calls {% seo %}.

Sitemaps with jekyll-sitemap

Same install pattern (Gemfile + _config.yml). It generates a sitemaps.org- compliant sitemap.xml at /sitemap.xml on every build.

It requires url: in _config.yml — without it, sitemap entries have no domain.

Controlling <lastmod>, in priority order:

  1. last_modified_at: in front matter (best — explicit control)
  2. Post creation date (fallback — often wrong for evergreen content updated later)
  3. Filesystem modification date (needs the non-whitelisted jekyll-last-modified-at)

Best practice: add last_modified_at: YYYY-MM-DD to every post, and bump it when you update content — that’s the freshness signal recrawl prioritization leans on.

Excluding pages:

# Per-page front matter
sitemap: false

# Global pattern (in _config.yml)
defaults:
  - scope:
      path: "assets/**/*.pdf"
    values:
      sitemap: false

Permalinks are set globally in _config.yml or overridden per page in front matter.

StylePatternSEO notes
date (default)/:categories/:year/:month/:day/:title.htmlDate-heavy, fragile if the post date changes
pretty/:categories/:year/:month/:day/:title/Trailing slash, no .html
none/:categories/:title.htmlNo date burial
Custom/:title/ or /:categories/:title/Most control — recommended for evergreen content

Recommended for most sites:

permalink: /:title/
# or
permalink: /:categories/:title/

Why avoid date-based URLs for evergreen posts:

  • Changing a post’s date: front matter changes its URL → inbound links break.
  • Deep hierarchy (/2019/03/14/post-title/) buries content for no reason.
  • (For news and journalism, date URLs are fine and expected — this is a per-context call, not a universal rule.)

Collections need their own permalink config:

collections:
  case_studies:
    output: true
    permalink: /case-studies/:name/

A warning on changing patterns: once URLs are indexed, switching permalink styles requires 301 redirects (use jekyll-redirect-from). Skip the redirects and you break link equity and generate 404s in Search Console.

Collections and SEO

Collections are Jekyll’s custom content types beyond posts and pages — docs sections, portfolio items, team members, case studies, FAQs. Two requirements make or break their SEO:

  1. output: true must be set. Without it, collection documents are never rendered as individual HTML files and therefore can’t be indexed. This is a silent indexing killer — the content exists in your repo but never becomes a crawlable page.

    collections:
      docs:
        output: true          # REQUIRED for indexable pages
        permalink: /docs/:name/
  2. Each document needs front matter — even an empty --- block. Without it, Jekyll treats the file as a binary static file: no Liquid processing, no metadata, no jekyll-seo-tag integration.

Note that collections aren’t included in RSS feeds (only posts are). For large documentation sites, collections are usually the right structure anyway.

Publishing states and incremental builds

Jekyll has several independent switches that control what actually ends up in the built site. Mixing them up produces either pages that silently never get built, or draft/future content that ships to production by accident:

  • Drafts (_drafts/) are excluded from a normal build entirely. They only appear when you run bundle exec jekyll serve --drafts (or build --drafts) locally — moving a post into _posts/ with a real date is what actually publishes it.
  • Future-dated posts (a date: later than the current time) are excluded by default. future: true in _config.yml, or the --future flag, includes them — handy for local preview, but leaving it on in a production config means scheduled posts go live the moment you build, not on their intended date.
  • published: false in front matter excludes a document from the build regardless of its date — a separate switch from drafts and future posts, and easy to leave set after testing a page you meant to ship.
  • Incremental regeneration (--incremental) is documented by Jekyll as experimental, and it tracks a limited dependency graph — mainly includes and layouts. A page that iterates site.posts or other collection data (a tag index, an archive, related-posts logic) can go stale under --incremental without Jekyll detecting that the underlying posts changed. Don’t run it as part of a production deploy; use a full bundle exec jekyll build for anything you’re pushing live.

Before trusting any of these, build without the flags you use locally (--drafts, --future, --incremental) and confirm the output in _site/ matches what you intend to publish — the safest default for a deploy pipeline is a clean, full, non-incremental build.

Custom head partials with Liquid

When jekyll-seo-tag isn’t enough, you build your own _includes/head.html:

<head>
  <meta charset="UTF-8">
  <title>
    {% if page.title %}{{ page.title }} | {{ site.title }}
    {% else %}{{ site.title }}{% endif %}
  </title>
  <meta name="description" content="
    {%- if page.description -%}{{ page.description }}
    {%- elsif page.excerpt -%}{{ page.excerpt | strip_html | strip_newlines }}
    {%- else -%}{{ site.description }}
    {%- endif -%}">
  <link rel="canonical" href="{{ page.url | prepend: site.url }}">
  {% seo %}
</head>

Key Liquid filters for SEO:

  • | strip_html — removes tags from an auto-excerpt (essential for clean descriptions)
  • | strip_newlines — removes line breaks from excerpts
  • | truncate: 160 — an optional template-side character cap, not a Google limit. Prefer an authored, page-specific description and preview the rendered snippet; fit varies by query, device, language, and script.
  • | prepend: site.url — builds absolute URLs for canonical and OG tags
  • | date_to_xmlschema — ISO 8601 dates for JSON-LD datePublished/dateModified
  • | default: fallback — fallback when a variable is nil

Custom JSON-LD beyond what jekyll-seo-tag emits:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": {{ page.title | jsonify }},
  "datePublished": "{{ page.date | date_to_xmlschema }}",
  "dateModified": "{{ page.last_modified_at | default: page.date | date_to_xmlschema }}",
  "author": {
    "@type": "Person",
    "name": "{{ page.author.name | default: site.author.name }}",
    "url": "{{ page.author.url | default: site.author.url }}"
  }
}
</script>

robots.txt is not auto-generated

Jekyll does not create robots.txt. You make it yourself in the site root. Include an empty front-matter block so Liquid processes the file (so {{ site.url }} resolves):

---
---
User-agent: *
Allow: /

Sitemap: {{ site.url }}/sitemap.xml

Without this, there’s no sitemap reference in robots.txt — and some crawlers use that as a discovery mechanism. (robots.txt controls crawling, not indexing — for the full picture see crawling.)

Common Jekyll SEO myths

“Jekyll handles SEO automatically.” The static output is crawler-friendly, but metadata requires explicit setup. Default themes often ship with no SEO tags at all.

“GitHub Pages is fine for SEO — it’s free.” GitHub Pages itself is fine — the static output is crawler-friendly regardless of host. The catches are the plugin whitelist (and the specific versions it pins, not just which plugins) and, if your brand depends on the URL, deciding on a custom domain before you build an audience on github.io.

“Static sites don’t need sitemaps.” Google can find pages via links, but a sitemap speeds discovery and carries <lastmod> signals. jekyll-sitemap makes it trivial.

“Jekyll is dead.” It’s in mature maintenance mode — v4.4.1 shipped in January 2025. Minimal new features, but maintained and secure, and GitHub Pages will support it indefinitely. For simple blogs and docs, it’s a solid, boring choice.

“I can use any plugin on GitHub Pages.” No — --safe plus a fixed whitelist. The fix is GitHub Actions, not more gems.

Where this fits

Jekyll is one of the six generators in the static site generators cluster — the GitHub Pages default and the one most developers meet first. For the broader context on how Google renders JavaScript and when you actually need it, see the JavaScript SEO hub. The build-freshness discipline that applies to every SSG — a static site is only as current as its last build — applies to Jekyll too: edits don’t reach search engines until you rebuild and redeploy.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin an expert quote first.