Static sites deliver fast performance but present a content management challenge: how do you let writers update blog posts without involving developers? The solution is combining Jekyll's static site generator with Strapi's headless CMS.
Jekyll transforms your content into secure, pre-rendered HTML that loads instantly, eliminates server costs, and deploys with a simple push to GitHub Pages.
Meanwhile, Strapi provides an intuitive admin interface where content teams can publish articles independently. The best part? Strapi's streamlined REST API makes it easy to pull that content automatically into your Jekyll build.
In Brief:
- Jekyll generates fast static HTML while Strapi v5 provides a content management interface, eliminating the need for developers to handle routine content updates
- Custom Ruby integration is required since existing gems don't support Strapi v5's flattened JSON responses, but the setup handles pagination and caching automatically
- Content relationships work directly in Liquid templates because v5 returns populated objects inline rather than nested under data.attributes wrappers
- GitHub Pages offers free hosting for the static output while editors manage posts through Strapi's admin panel, creating a workflow that separates content from deployment
What Is A Static Blog?
A static website contains web pages with fixed content—HTML files that show the same information to every visitor. Unlike dynamic sites, static pages don't need server-side processing or database queries to load.
You publish static sites by uploading HTML files to any web server or storage service. Since there's no database to compromise and no server rendering on each request, static sites load faster and present fewer security vulnerabilities than dynamic alternatives
Technology Stack for Integration
Pairing Jekyll with Strapi v5 gives you the best of two proven worlds: static speed on the frontend and a flexible headless CMS on the backend.
- Jekyll transforms your Markdown into static HTML through a Ruby-based generator that handles posts, categories, and permalinks automatically. It eliminates database dependencies, reduces security risks, and deploys directly to GitHub Pages with a simple
git push
. - Strapi v5 provides a modern, TypeScript-powered CMS with a simplified API format that removes the nested structure from previous versions. The integration point between these tools requires custom work—existing gems like
jekyll-strapi-4
aren't compatible with v5's flattened responses, so you'll need to build your own data fetching solution.
While JavaScript alternatives like Astro or Next.js offer smoother integration, Jekyll remains compelling when GitHub Pages compatibility and zero hosting costs are priorities.
Part 1: Set Up the Strapi v5 Backend
You'll start on the backend so Jekyll has clean, predictable data to consume. Node.js 18 or later is required—the headless CMS refuses to boot on older runtimes, as documented in the installation guide.
Install Strapi v5
Open your terminal, pick a project folder, and run:
1npx create-strapi@latest my-blog-cms
2cd my-blog-cms
3npm run develop
When the server starts, visit http://localhost:1337/admin
, create the first admin user, and land in the Admin Panel.
Model Your Blog Data
Inside the "Content-type Builder", create three content types that form your blog's foundation:
- Start with Articles, which need a Title field (Text, required), a Slug field (UID based on Title, required), Content (Rich Text, required), and Featured Image (Media, single)
- Next, build Categories with Name (Text, required) and Description (Text)
- Finally, add Authors with Name (Text, required), Bio (Text), and Avatar (Media, single)
Connect these content types through relationships. Set Articles to Categories as many-to-many, since posts can belong to multiple categories and categories can contain multiple posts. Set Articles to Author as many-to-one, since each post has one author, but authors can write multiple posts.
Each time you click "Save", the system restarts and rebuilds its schema. In v5, the new Document System stores these definitions in clear JSON files, and the generated REST responses are already flat—no more data.attributes
wrapper from v4—so Liquid templates in Jekyll won't need extra digging.
Example response for a single article:
1{
2 "id": 1,
3 "title": "Why Jekyll Loves Strapi",
4 "slug": "jekyll-loves-strapi",
5 "content": "<p>…</p>",
6 "featuredImage": "https://…/uploads/header.jpg",
7 "author": { "id": 3, "name": "Ari" },
8 "categories": [
9 { "id": 2, "name": "Headless CMS" }
10 ]
11}
Because every field lives at the top level, you'll map keys directly to Liquid variables later.
Validate Fields Early
Use the "Advanced settings" tab for each field to add constraints that prevent editors from publishing data that would break the static build.
Mark Title, Slug, and Content as "required", add a unique constraint on Slug, and limit Featured Image to 1 file with accepted image MIME types.
Configure Api Permissions And Test Endpoints
Your headless CMS ships with everything locked down. In the Admin Panel navigate to Settings → Users & Permissions → Roles → Public:
- Enable
find
andfindOne
for Article, Category, and Author. - Leave create/update/delete unchecked—those will be available only to authenticated users.
Save, then hit http://localhost:1337/api/articles
in the browser or with curl
. You should receive the flat JSON shown above. If you see 403 Forbidden
, double-check the role settings.
For relationship population, chain the populate
query:
1/api/articles?populate=author,categories
Unlike v4, the populated objects come back inline, so your Jekyll fetcher won't need to walk a second layer.
During local development Jekyll will run on another port, so add the origin to config/middlewares.js
:
1module.exports = [
2 'strapi::errors',
3 {
4 name: 'strapi::cors',
5 config: {
6 origin: ['http://localhost:4000'],
7 methods: ['GET']
8 }
9 },
10];
Common hiccups include Node version mismatches (update to LTS 18+), saving a Content Type but seeing no endpoint (restart npm run develop
), and empty arrays in the JSON (verify sample entries are Published, not in Draft).
With permissions sorted and endpoints verified, the backend is ready. Next, you'll wire these endpoints into Jekyll's build pipeline.
Part 2: Build the Jekyll Integration
Now that your backend is ready, it's time to set up Jekyll and connect it to Strapi.
Install Ruby and Configure Jekyll
Get a reliable Ruby runtime on your machine—Jekyll breaks if the version drifts. On Windows, use RubyInstaller with DevKit to avoid missing-msys2
errors. macOS and Linux users should use rbenv
to pin project-specific versions in .ruby-version
, avoiding Ruby versioning conflicts.
Once Ruby is installed, install Bundler:
1gem install bundler
Create a fresh site:
1jekyll new my-blog
2cd my-blog
Add a Gemfile
if Jekyll didn't generate one:
1source 'https://rubygems.org'
2gem 'jekyll', '~> 4.3'
Lock dependencies with bundle install
—Bundler guarantees consistent gem versions across environments.
If Ruby issues persist, containerize instead. A seven-line Dockerfile based on jekyll/jekyll
reproduces a clean environment on any OS and keeps your host free of gem conflicts.
Create Custom API Integration Without Gems
Existing plugins hard-code the nested data.attributes
responses from previous versions, breaking on v5's flattened JSON. Build your own integration using Jekyll's data files and custom generators.
Create _plugins/fetch_strapi.rb
in your project root:
1# frozen_string_literal: true
2require 'net/http'
3require 'json'
4require 'fileutils'
5
6module Jekyll
7 class FetchStrapi < Generator
8 safe true
9 priority :highest
10
11 API_BASE = ENV.fetch('STRAPI_URL', 'http://localhost:1337/api').freeze
12 CACHE_DIR = '.cache/strapi'.freeze
13 COLLECTIONS = %w[articles categories authors].freeze
14
15 def generate(site)
16 FileUtils.mkdir_p(CACHE_DIR)
17 COLLECTIONS.each do |type|
18 site.data[type] = fetch(type)
19 end
20 end
21
22 private
23
24 def fetch(type, page = 1, results = [])
25 url = URI("#{API_BASE}/#{type}?pagination[page]=#{page}&pagination[pageSize]=100")
26 response = Net::HTTP.get_response(url)
27 raise "Strapi error: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
28
29 json = JSON.parse(response.body)
30 results.concat(json['data'])
31 if json.dig('meta', 'pagination', 'page') < json.dig('meta', 'pagination', 'pageCount')
32 fetch(type, page + 1, results)
33 else
34 cache_file = File.join(CACHE_DIR, "#{type}.json")
35 File.write(cache_file, JSON.pretty_generate(results))
36 results
37 end
38 rescue StandardError => e
39 Jekyll.logger.warn "Strapi fetch failed", e.message
40 cached = Dir["#{CACHE_DIR}/#{type}.json"].first
41 cached ? JSON.parse(File.read(cached)) : []
42 end
43 end
44end
The plugin hits /api/articles
, /api/categories
, and /api/authors
, follows pagination, and caches locally so repeated builds don't hammer your CMS. Since v5 returns flat records without attributes
wrappers, each element in site.data['articles']
works directly with Liquid.
For private content, replace Net::HTTP.get_response
with a call that adds your JWT in the Authorization
header. Store tokens in environment variables.
Build Templates for Dynamic Content
With data in _data/articles.json
, Liquid templates work as if posts were local Markdown. Jekyll's layout engine handles collections, permalinks, and pagination automatically.
index.html
lists recent posts:
1---
2layout: default
3title: Blog
4---
5<h1>Latest articles</h1>
6{% for post in site.data.articles limit:6 %}
7 <article>
8 <h2><a href="/{{ post.slug }}/">{{ post.title }}</a></h2>
9 <p>{{ post.excerpt | truncate: 160 }}</p>
10 </article>
11{% endfor %}
An individual article page pulls related data:
1---
2layout: default
3permalink: "/:slug/"
4---
5{% assign article = site.data.articles | where: "slug", page.slug | first %}
6<h1>{{ article.title }}</h1>
7<img src="{{ article.cover.url }}" alt="{{ article.title }}">
8{{ article.content | markdownify }}
9
10<p>Category:
11 {% for cat in article.categories %}
12 <a href="/category/{{ cat.slug }}/">{{ cat.name }}</a>{% unless forloop.last %}, {% endunless %}
13 {% endfor %}
14</p>
15
16<p>By <a href="/author/{{ article.author.slug }}/">{{ article.author.name }}</a></p>
The CMS sends full objects for relations when you use populate=*
, so you can loop over article.categories
or article.author
directly. Add paginate: 10
to _config.yml
for automatic pagination.
Images arrive as absolute URLs from the backend. Use the srcset
attribute for responsive variants stored in the Media Library, or pipe through build-time image plugins. Wrap <img>
tags in conditionals to handle missing images.
That's the core workflow: fetch at build time, store in _data
, render with Liquid. You keep Jekyll's static performance while giving teammates the editing experience they expect.
Part 3: Add Advanced Features and Deploy
With the foundations in place, you can now enrich the blog and push it online.
Handle Content Relationships and Generate Dynamic Pages
Strapi v5 returns a clean, flat JSON object, so showing relations inside Jekyll is mostly a matter of mapping IDs to their companion records. When you fetch articles from /api/posts?populate=author,category
the response already embeds each article's author
and category
objects, eliminating the deep nesting that complicated v4 integrations.
Since everything arrives in one request, you can loop through a single data file to render category badges beside every post title and an author block with avatar and bio at the bottom of the article.
For archive pages, split the same dataset in memory instead of calling the API again. Group posts by category.slug
to generate a /categories/{slug}/index.html
, and by author.slug
for /authors/{slug}/index.html
. Liquid's group_by
filter keeps the template concise, while the flattened payload prevents the multi-level look-ups you'd need otherwise.
The backend still paginates large collections, so instruct your custom Ruby fetcher to walk the pagination
object and concatenate results. Batching requests this way avoids opening dozens of short-lived connections.
Once the records land in _data/
, cache them to disk. On the next build your script can skip the network step entirely unless a webhook signals new content. The same cache lets you generate extras—tag clouds, related-post widgets—without hitting the CMS again.
Optimize Build Performance and Deploy to Production
Even a modest blog grows fast. Without safeguards, build times creep from seconds to minutes. First, enable Jekyll's incremental engine (jekyll build --incremental
). This feature can dramatically reduce rebuild times by processing only changed files.
Next, profile the site (jekyll build --profile
) to surface costly includes. If heavy SASS compilation dominates, move the asset pipeline to Gulp. Delegating CSS and JS processing reduces Jekyll's workload.
For very large collections, request only the latest N posts from your CMS or paginate API calls so the build doesn't parse thousands of records it never displays.
External APIs introduce another issue: rate limits. Store the timestamp of every successful API response and retry only after the cache expires. When a limit error still slips through, wait, then retry with exponential back-off instead of failing the whole build.
Deployment is straightforward. Because Jekyll outputs plain HTML, you can commit the _site
folder to a gh-pages
branch and host it for free on GitHub Pages—which runs Jekyll natively and needs no server setup. If you prefer CI-driven previews or staging environments, point the build artifact at Netlify or Vercel. Both accept static bundles with zero configuration. For continuous updates, add a webhook that triggers your CI pipeline every time an editor publishes a new post, guaranteeing readers see fresh content without manual redeploys.
Resolve Common Integration Issues
Ruby version mismatches cause most Jekyll-Strapi integration headaches. Your OS ships with one Ruby version, GitHub Pages expects another, and builds fail mysteriously. Fix this by installing rbenv to isolate Ruby versions, then create a .ruby-version
file to pin the exact release your project needs.
Always install gems through Bundler—it locks your entire dependency tree and prevents version conflicts that break your build. The Snyk gem management guide explains why this approach saves you from dependency hell.
Windows developers hit another common snag: native extensions that refuse to compile. The wdm
gem is a frequent culprit. Follow Jekyll's official Windows installation guide to install MSYS2 tools—they provide the compilation environment Windows lacks by default.
If Ruby setup still frustrates you, containerize your entire build with Docker and let the image handle Ruby, Bundler, and system dependencies.
Connectivity issues between Jekyll and your headless CMS break builds in subtle ways. Network timeouts, rate limits, or incorrect authentication tokens cause your fetch scripts to fail mid-render. Here's how to troubleshoot effectively:
- Lock down permissions: Configure Public role for read-only access
- Secure authentication: Use environment variables for JWTs, never commit tokens
- Test connections: Verify API responses in browser before building
- Configure CORS: Whitelist your development origin in middleware
- Implement caching: Store successful responses to reduce API calls
- Add fallbacks: Use exponential backoff when rate limits occur
Large content libraries create performance bottlenecks that eat your CI minutes and slow local development. Run jekyll build --profile
to identify which templates consume the most time. Enable incremental builds to skip unchanged pages—one development team reported 10× faster build cycles after implementing this optimization.
Move asset compilation to dedicated tools like Gulp, as Savas Labs demonstrates, and exclude heavy directories in your _config.yml
. If builds still take more than a few minutes, consider migrating to Astro or Next.js for better performance while keeping Jekyll for archive content.
Launch Your Jekyll and Strapi v5 Blog
This setup gives you static HTML files that load quickly and deploy anywhere. Your content team updates posts through Strapi's admin interface while Jekyll handles the build process.
The initial Ruby configuration and custom integration take time to set up. Once working, you get free GitHub Pages hosting while editors publish without developer involvement. Both tools need occasional updates.
If build times become problematic, consider migrating to Astro or Next.js. Both work well with Strapi's REST APIs.
Contact Strapi Sales
Quadri Sheriff is an aspiring technical writer and a software programmer. He is a very shy individual who loves writing, coding and reading in his spare time. He is currently interested in learning more about blockchain and the Jamstack.