Jekyll Plugin Ecosystem

Jekyll is mature and stable, and a production setup is mostly about dependency discipline rather than raw speed: pin your gems, group plugins correctly, commit the lockfile, and keep builds reproducible across machines and CI. Because Jekyll is a Ruby application whose plugins are ordinary gems, the failure modes are Ruby failure modes — a transitive dependency drifts, a native extension stops compiling on a new interpreter, or a plugin that should run in production quietly gets skipped. This guide covers the plugin and dependency model, a working CI pipeline, the build-optimization levers that actually move the needle, how collections and pagination scale the page count, when to replace a plugin outright, and how to measure all of it.

Within the wider engine decision, Jekyll is the "stable and predictable, not the fastest" option; the full trade-off against Astro, Eleventy, Hugo, and Next.js export lives on the parent guide, Choosing the Right Static Site Generator for Production, and the side-by-side scoring is in the SSG framework selection matrix. If you have already committed to Jekyll, everything below assumes you want to keep it fast and its builds reproducible.

Jekyll plugin categories and where they run in the build Three categories of Jekyll plugins — generators, converters, and tags or filters — feed the Liquid render stage, which produces the _site output, with build-time SEO, feed, and sitemap plugins shown in the default Bundler group. Plugin categories feed one Liquid render Generators feed, sitemap, archives Converters Kramdown, Rouge Tags & filters seo-tag, custom Liquid filters Liquid render layouts + includes per page _site output disposable HTML deploy target Build-time plugins go in the default Bundler group so CI runs them too.
Generators, converters, and tags/filters all feed one Liquid render that emits the disposable `_site`; build-time plugins must live in the default Bundler group so CI runs them.

Plugin Architecture & Dependency Management

Unpinned gems are the main source of "works on my machine" build failures. Pin core dependencies in the Gemfile and commit Gemfile.lock. The standard SEO/feed/sitemap plugins run as part of the build, so they belong in the default group, not :development — only genuinely local-only tooling (like jekyll-remote-theme when you preview themes locally) goes under :development:

# Gemfile
source "https://rubygems.org"

gem "jekyll", "~> 4.3"
gem "jekyll-seo-tag", "~> 2.8"
gem "jekyll-feed", "~> 0.17"
gem "jekyll-sitemap", "~> 1.4"
gem "webrick", "~> 1.8"   # not bundled with Ruby 3.0+

group :development do
  gem "jekyll-remote-theme", "~> 0.4"
end

Validate plugin compatibility against your Ruby version (3.3+ is a safe modern target) before upgrading the runtime. Jekyll plugins fall into three categories, and knowing the category tells you when a plugin runs and therefore where it can slow the build:

  • Generators create new pages during the :site, :post_read → generate phase. jekyll-feed, jekyll-sitemap, and jekyll-archives are generators; their cost scales with the number of documents, not the number of templates.
  • Converters turn one format into another — Kramdown for Markdown, Rouge for syntax highlighting, Sass for stylesheets. They run once per file of that type, so a slow converter (large SCSS graphs, expensive highlighting) shows up as a flat tax across the build.
  • Tags and filters extend Liquid — jekyll-seo-tag's {% seo %} tag, or a custom filter you register. These run inside the render loop, once per invocation, which is why a filter dropped into a shared layout is the classic cause of a build-time regression.

Plugin load order matters when plugins depend on each other's output. Jekyll loads gems listed under the plugins: key in _config.yml in the order given, and files in _plugins/ alphabetically, so a hook that reads data another generator produces must be ordered to run after it. When a plugin only needs to run for real deploys, gate it on the environment inside the plugin itself (return unless Jekyll.env == "production") rather than juggling Bundler groups for something Bundler was never meant to express.

One dependency to watch is any gem with a native extension (C code compiled at install time) — nokogiri, sassc, ffi. These are the gems that break first on a Ruby upgrade, because the precompiled binary for your platform may lag the interpreter. Pin them explicitly and upgrade them on their own branch so a broken build is one line in the lockfile, not a mystery.

CI/CD Pipeline Integration

Use ruby/setup-ruby with bundler-cache: true — it installs gems and caches vendor/bundle keyed on your lockfile, which removes the most common source of slow, flaky Jekyll CI:

name: Jekyll CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true
      - run: JEKYLL_ENV=production bundle exec jekyll build

On a 4,000-post documentation blog, switching from a cold bundle install every run to bundler-cache: true cut the install phase from ~48s to ~6s:

CI stepWithout cacheWith bundler-cache
Gem install48s6s
Jekyll build71s71s
Total job119s77s

bundler-cache: true is doing two things: it runs bundle install and it wires up actions/cache keyed on Gemfile.lock, so the cache invalidates automatically the moment you change a dependency. Never hand-roll a cache key off Gemfile alone — you will serve a stale vendor/bundle after a lockfile bump and spend an afternoon chasing a version mismatch that only reproduces in CI.

Add bundle exec jekyll doctor as a pre-deploy step to catch configuration problems and known issues (deprecated config keys, colliding permalinks, URLs that will 404) before they ship. Because GitHub Pages runs Jekyll in safe mode — it ignores _plugins/ and allows only a whitelisted gem set — any site that needs custom plugins must build in CI with its own Gemfile and publish the compiled _site, rather than pushing source and letting Pages build it. The deploy step is then just an upload of the artifact:

      - name: Build
        run: JEKYLL_ENV=production bundle exec jekyll build
      - name: Deploy
        uses: actions/upload-pages-artifact@v3
        with:
          path: _site

Setting JEKYLL_ENV=production is not cosmetic: several plugins change behaviour under it (jekyll-feed and jekyll-seo-tag emit production URLs, analytics includes switch on), and forgetting it is a common cause of a build that "works" but ships development output. For a fast feedback loop, split the workflow into a lint job (jekyll doctor plus an HTML validator like htmlproofer against _site) that fails fast, and a deploy job that only runs on the default branch. Teams comparing pipelines across frameworks can benchmark against Astro vs Eleventy for Documentation Sites, and the Next.js-based alternative is covered in Next.js Static Export for Content Sites.

Build Optimization & Caching

Two levers help most. First, incremental builds--incremental regenerates only changed pages — but treat it as a development-time accelerator: Jekyll's incremental regeneration is known to miss cross-page dependencies (a changed include that affects many pages), so use full builds for production deploys. Second, keep heavy logic out of Liquid: custom filters that run on every page in a large collection dominate build time. Pre-compute data in a Ruby plugin or a Jekyll hook (:site, :post_read) so the work happens once per build rather than once per page.

A worked example — moving a per-page tag-count filter into a single hook on the 4,000-post blog took the Liquid render phase from ~71s to ~52s:

# _plugins/precompute_tag_counts.rb
Jekyll::Hooks.register :site, :post_read do |site|
  counts = Hash.new(0)
  site.posts.docs.each { |doc| Array(doc.data["tags"]).each { |t| counts[t] += 1 } }
  site.data["tag_counts"] = counts   # now O(1) lookups in templates
end

Before you optimise anything, find the bottleneck with a profiling build:

JEKYLL_ENV=production bundle exec jekyll build --profile

--profile prints per-template render time so you can see which layout or include is the bottleneck.

Where a 4,000-post Jekyll build spends its time Horizontal bar breakdown of a 4,000-post Jekyll build. The gem-install bar is shown twice — 48 seconds cold versus 6 seconds with bundler-cache — while the build itself splits into read plus generate at about 12 seconds, Liquid render at about 52 seconds, and write _site at about 7 seconds. Caching only shortens the install bar; the Liquid render phase dominates the build and is where optimization pays off. Where a 4,000-post Jekyll build spends its time 0 15s 30s 45s Gem install cached 6s cold 48s Read + generate ~12s Liquid render dominates the build ~52s Write _site ~7s Caching only shortens the install bar — the render phase is what you optimize.
On a large Jekyll site, `bundler-cache` collapses gem install from 48s to 6s, but that never touches the build itself — Liquid render dominates, so per-page filter work and `include_cached` are where the real time goes.

A third lever, once you have flattened per-page filters, is {% include_cached %} from jekyll-include-cache. A normal {% include %} re-renders its Liquid every time it appears; include_cached memoises the rendered output keyed on its parameters, so a navigation menu or footer that is identical on 4,000 pages is rendered once and reused. On sites with heavy shared partials this is often a larger win than incremental builds, and unlike --incremental it is safe for production because the cache key is explicit.

If you cache anything in CI, cache Jekyll's incremental metadata, not the output — .jekyll-cache/ and .jekyll-metadata are what speed up regeneration; _site/ is the disposable result and re-caching it just moves bytes around. For Markdown-heavy workloads specifically, compare Jekyll's Liquid render against Eleventy's approach in Eleventy vs Jekyll for Markdown-Heavy Blogs before committing to a scaling strategy.

Replacing or Migrating Plugins

The most painful Jekyll dependencies are abandoned gems with native extensions: when a Ruby version bump breaks one, your build stays broken until someone patches it upstream — and if nobody does, you are maintaining a fork. Three defensive moves help. First, prefer plugins from the maintained jekyll/ org or the actively maintained community set over one-off gems; check the last release date and open-issue response time before you adopt anything. Second, when a plugin's behaviour is simple, reimplement it as a small in-repo _plugins/ file you control — a 15-line hook that does exactly what you need has no upstream to abandon it. Third, keep the plugin surface small: every gem you drop is one fewer thing to audit on the next Ruby upgrade.

When you do replace a plugin, do it behind a green build. Add the replacement, delete the old gem, run bundle exec jekyll doctor, and diff the generated _site so you can prove the output is byte-identical (or account for every difference) before merging. This turns a risky swap into a reviewable one.

If the dependency story is the reason you are leaving Jekyll entirely, the mapping from Jekyll plugins to their nearest equivalents — and which ones simply become built-in behaviour — is laid out in Replacing Jekyll Plugins When Migrating to Eleventy. The same "native over plugin" lesson applies when porting logic to faster engines: in Hugo Build Times for Large Repositories, Hugo's shortcodes and image methods replace whole categories of Jekyll gems, and the render-hook and caching techniques show what "native" buys you in raw throughput.

Collections and Pagination at Scale

Large Jekyll sites usually organise content into collections (_posts, _docs, custom collections defined under collections: in _config.yml). Two settings on a collection govern how much work the build does. output: true makes Jekyll render a page per document — only set it on collections you actually publish, because rendering documents you never link wastes build time. And front matter defaults scoped to a collection let you stop repeating layout and permalink keys in every file:

# _config.yml
collections:
  docs:
    output: true
    permalink: /docs/:path/

defaults:
  - scope: { path: "", type: "docs" }
    values: { layout: "doc", toc: true }

Setting output: false (the default) still lets you iterate over a collection's documents in Liquid — the data is loaded, it just is not written as standalone pages. That is the right choice for a collection you only render inside other pages (team bios embedded on an about page, say), and it can shave real time off a large build because Jekyll skips the render-and-write step for every document.

Pagination is the other scaling concern, and it is where page counts get out of hand. The built-in paginator only walks _posts; for paginating an arbitrary collection use jekyll-paginate-v2, and cap per_page so a large archive does not generate thousands of nearly empty index pages. Each generated page is a full Liquid render, so an over-eager paginator can quietly double a build's page count — a blog paginating posts, tags, and categories at per_page: 5 can emit more index pages than it has articles. Prefer larger page sizes plus client-side filtering over many tiny server-rendered index pages when the archive is large. Where this kind of structural work becomes the bottleneck, it is worth weighing the engine itself against the alternatives in Astro vs Eleventy for Documentation Sites.

Measuring Build Performance

Track total build duration and the --profile table across commits, and alert when build time jumps more than ~10–15%. A regression almost always shows up as one layout or include suddenly dominating the profile — usually a new custom filter that runs per page, or an include pulled into a high-traffic layout.

Measure warm, not cold, and average several runs so runner noise does not read as a regression. A minimal recorded baseline is just the build time and page count captured on every CI run:

JEKYLL_ENV=production bundle exec jekyll build --profile 2>&1 | tee build.log
pages=$(find _site -name '*.html' | wc -l)
echo "pages=$pages seconds=$SECONDS" >> build-history.tsv

Committing build-history.tsv (or pushing it to an artifact) gives you the trend line that tells a real regression apart from a slow runner. Two derived numbers are worth watching: milliseconds per page (which isolates render efficiency from sheer growth) and the share of total time in the top template (a spike there points straight at the offending include). The controlled-benchmark discipline in How to Benchmark Hugo vs Astro Build Speeds — fixed corpus, warm cache, several runs averaged — transfers directly to tracking a single engine over time.

Common Pitfalls

  • Unpinned gems: non-deterministic dependency resolution breaks CI unpredictably. Commit Gemfile.lock and use ~> constraints.
  • Wrong Bundler group: putting jekyll-seo-tag or jekyll-feed under :development means CI skips them and ships pages missing meta tags or a feed. Build-time plugins go in the default group.
  • Trusting --incremental in production: it can serve stale pages when a shared include changes. Use it locally; do full builds for deploys.
  • Heavy Liquid filters per page: a custom filter run on every item in a large collection dominates build time. Pre-compute in a hook so the work happens once.
  • Caching _site instead of metadata: the output directory is not what makes rebuilds fast. Cache vendor/bundle and .jekyll-cache/.
Three tiers of Jekyll plugin risk Three panels. Allowlisted plugins run everywhere including the hosted build. Maintained third-party gems need your own pipeline but are safe. Unmaintained or custom plugins are the migration risk, because nobody else will fix them. Three tiers of Jekyll plugin risk Allowlisted sitemap, feed, seo-tag redirect-from, paginate run on the hosted build no pipeline required Third-party, maintained need your own build pin the version check release cadence replaceable if abandoned Custom or unmaintained only you can fix them block a Jekyll upgrade usually 2-3 per site the real migration cost Audit annually: a gem with no release in two years is a decision waiting to happen.
The third column is what decides whether a Jekyll site can be upgraded in an afternoon or a fortnight.

Key Takeaways

  • Pin gems, commit Gemfile.lock, and keep build-time plugins in the default Bundler group.
  • Use bundler-cache: true in CI — it removes the most common source of slow, flaky Jekyll jobs.
  • Reserve --incremental for local authoring; ship full builds to production.
  • Move per-page logic into a :site, :post_read hook so it runs once, not once per page.
  • Profile with jekyll build --profile and alert on build-time regressions in CI.

FAQ

How do I safely upgrade Jekyll plugins without breaking CI?

Run bundle update --conservative on a branch, run bundle exec jekyll doctor, and diff the generated _site output before merging. Conservative updates change only the gem you name and its direct requirements, so the blast radius stays small and reviewable.

Can I use Jekyll plugins with GitHub Pages?

GitHub Pages only allows a whitelisted plugin set. To use any plugin, build in CI with your own Gemfile and deploy the compiled _site to your pages branch instead of relying on GitHub's built-in build.

What is the optimal caching strategy for Jekyll in CI?

Cache vendor/bundle via bundler-cache: true so gems are not reinstalled every run, and cache .jekyll-cache/ if you use incremental builds. Do not cache _site, because the output directory is the disposable result, not what makes rebuilds fast.

How do I measure Jekyll build performance?

Run jekyll build --profile to print per-template render time, and track that table plus total build duration across commits. A layout or include that suddenly dominates the profile is usually the regression.

Which plugins belong in the default Bundler group versus development?

Build-time plugins like jekyll-seo-tag, jekyll-feed, and jekyll-sitemap run during the production build, so they belong in the default group. Only genuinely local-only tooling, such as jekyll-remote-theme used for previewing themes, belongs under the :development group.

Is jekyll --incremental safe for production deploys?

No. Jekyll's incremental regeneration is known to miss cross-page dependencies, such as a changed include that affects many pages, so it can serve stale output. Use it as a local development accelerator and run full builds for production deploys.