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 18, 2026 · Advanced
demand #2 in Static Site Generators#27 in Platform SEO#176 in Technical SEO#236 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 ServiceTurning HTML, CSS, and JavaScript into the final visual page and DOM., no Wave 2 delay. The SEO work is configuration, not architecture. Install jekyll-seo-tag (title, description, canonical, OG, Twitter Card, JSON-LDJSON-LD (JavaScript Object Notation for Linked Data) is a script-based structured data format, typically paired with the schema.org vocabulary to describe page content for search engines and AI systems. Google recommends it over Microdata and RDFa because it's the easiest format to implement and maintain at scale — but all three work, and structured data isn't a ranking signal.) 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 URLHow search engines pick one canonical URL among duplicates and consolidate signals onto it.. 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 generatorA build tool that pre-renders every page into static HTML files at deploy time, so the server delivers complete HTML without executing code per request — eliminating JavaScript rendering delays for search engine crawlers.: 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 → indexStoring a crawled page in the search index so it can appear in results. Crawled is not the same as indexed — Google selects what to keep, and indexing isn't guaranteed., and renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 budgetThe number of URLs an engine will crawl in a timeframe.. 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 generatorA build tool that pre-renders every page into static HTML files at deploy time, so the server delivers complete HTML without executing code per request — eliminating JavaScript rendering delays for search engine crawlers. the lowest-risk architecture for indexability.

The downstream benefits follow:

  • Faster TTFBTime to First Byte — the time from the start of a request to when the first byte of the response arrives. It's a diagnostic metric (not a Core Web Vital) and a major input to FCP and LCP; ≤0.8 s is good.. 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 LCPLargest Contentful Paint — render time of the largest visible image or text block, relative to when the page started loading. ≤2.5 s (at the 75th percentile) is good. and the rest of Core Web VitalsGoogle's three real-user UX metrics — LCP (loading), INP (responsiveness), and CLS (visual stability) — used by Google's ranking systems, with no official weight attached, measured on field data..
  • No required JS payload for content → better FCPFirst Contentful Paint — the time from when a page starts loading to when any part of its content (text, image, SVG, or non-white canvas) first renders. Good is ≤1.8 s at the 75th percentile. It's a diagnostic metric, not a Core Web Vital. and LCPLargest Contentful Paint — render time of the largest visible image or text block, relative to when the page started loading. ≤2.5 s (at the 75th percentile) is good. than a hydrating SPA.
  • Proper HTTP status codesAn HTTP status code is the three-digit number a server returns with every response to tell a browser or crawler what happened to its request — success, redirect, client error, or server error. For SEO the code matters as much as the content: it tells Google and Bing whether to index a page, follow a redirect, retry later, or drop the URL from the index. at the CDN layer, not client-side error handling.

But none of that changes the fundamentals: static sites still need meta tagsMeta tags are HTML elements in a page's head that pass metadata about the page to search engines and browsers. For SEO only a few matter — the title element, the meta description, and the robots meta tag — while meta keywords and most others are ignored., sitemapsA sitemap is a file that lists the pages, images, videos, and other files on your site so search engines can discover them. It helps discovery, but submitting a sitemap doesn't guarantee crawling or indexing., canonical URLs, structured dataStructured data is a standardized way of labeling page content (using the schema.org vocabulary in JSON-LD, Microdata, or RDFa) so search engines can understand its meaning. It's not a direct ranking factor — its value is rich results and entity understanding., and good content. Jekyll’s output is crawlerA crawler — also called a spider or bot — is an automated program that fetches web pages, extracts their links, and queues new URLs to visit. Search engines use crawlers to discover and download content for their index.-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 HTTPSHTTPS is the encrypted version of HTTP — it uses TLS to authenticate the server and protect data in transit between a browser and a website. Google announced it as a lightweight ranking signal in 2014 and today conditionally prefers HTTPS pages as canonical; Chrome marks plain HTTP pages 'Not Secure.' 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 redirectsA 301 redirect is the HTTP status code for a permanent move: it tells browsers and search engines a URL has moved for good, and it's the strongest signal for consolidating a page's ranking signals onto the new URL. Google says permanent redirects don't cause a loss in PageRank. (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 linksAn internal link is a hyperlink from one page on a website to another page on the same website. Internal links help search engines discover your pages and pass ranking signals (PageRank and anchor-text context) between them. — 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 GraphOpen Graph (OG) tags are `<meta>` elements in a page's head, defined by the Open Graph protocol (ogp.me, created by Facebook), that describe a page as a shareable object — its title, description, image, URL, and type. They control how a link preview card looks when the page is shared on Facebook, LinkedIn, Slack, Discord, WhatsApp, and iMessage. They are not a direct Google ranking factor, though Google reads og:title, og:image, and og:site_name as inputs to how a result appears. tags (og:title, og:description, og:url, og:site_name, og:image)
  • Twitter Card tagsTwitter Card tags (now often called \"X Cards\") are <meta name=\"twitter:...\"> tags in a page's <head> that tell X's link-unfurling system what rich preview to build for a shared URL — image, headline, description, and (for player cards) an embeddable frame. The four card types are summary, summary_large_image, app, and player. Every twitter:* property falls back to its og:* equivalent if missing — except twitter:card itself, which has no fallback and must be set explicitly. They are not a Google or Bing ranking factor. (twitter:card, twitter:title, twitter:description, twitter:creator, twitter:image)
  • JSON-LDJSON-LD (JavaScript Object Notation for Linked Data) is a script-based structured data format, typically paired with the schema.org vocabulary to describe page content for search engines and AI systems. Google recommends it over Microdata and RDFa because it's the easiest format to implement and maintain at scale — but all three work, and structured data isn't a ranking signal. structured data (BlogPosting for posts, WebSite for the home page)
  • PaginationPagination splits a large set of content — product listings, blog archives, search results — across multiple sequentially numbered URLs. For SEO, each paginated page should be crawlable, indexable, and self-canonical; Google no longer uses rel=prev/next, but Bing still does. 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 slashA trailing slash is the forward slash (/) at the end of a URL — example.com/page/ versus example.com/page. Except at the bare root domain, the two versions are different URLs to search engines, so you pick one format and enforce it., 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 redirectsA redirect sends browsers and crawlers from a requested URL to a different one. An HTTP redirect specifically is a 3xx status code paired with a Location header; meta refresh and JavaScript redirects achieve a similar navigation without being a 3xx response themselves. Permanent redirects (301/308) are Google's signal the target should be canonical; temporary ones (302/303/307) aren't. (use jekyll-redirect-from). Skip the redirects and you break link equity and generate 404s in Search ConsoleGoogle's free tool for monitoring crawling, indexing, and search performance..

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 indexingStoring a crawled page in the search index so it can appear in results. Crawled is not the same as indexed — Google selects what to keep, and indexing isn't guaranteed. 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 feedsAn RSS or Atom feed is an XML file listing a site's most recently published or updated URLs. Search engines accept it as a sitemap-style discovery signal for fresh content — not a replacement for a full XML sitemap. (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 | truncate: 160 }}
    {%- 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 — caps a description at 160 characters
  • | 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 crawlingCrawling is how search engines use automated bots (like Googlebot and Bingbot) to discover URLs and download pages. A page has to be crawlable to be indexed, but crawling on its own isn't a ranking factor., not indexing — for the full picture see crawlingCrawling is how search engines use automated bots (like Googlebot and Bingbot) to discover URLs and download pages. A page has to be crawlable to be indexed, but crawling on its own isn't a ranking factor..)

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 generatorsA build tool that pre-renders every page into static HTML files at deploy time, so the server delivers complete HTML without executing code per request — eliminating JavaScript rendering delays for search engine crawlers. 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 SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. 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 a quote first.