A Jekyll template for basic Contentful-backed sites: content is fetched from Contentful at build time and deployed to GitHub Pages via GitHub Actions.
Native GitHub Pages builds run Jekyll in "safe mode," which disables custom plugins and network access — so this site can't rely on GitHub's built-in Jekyll build. Instead, a GitHub Actions workflow (.github/workflows/deploy.yml) runs jekyll build with full plugin support and deploys the resulting _site/ to Pages.
- Install dependencies:
bundle install - Copy
.env.exampleto.envand fill in your Contentful credentials (in Contentful: Settings → API keys → add or open an API key for a Content Delivery API token; the space ID is on the same page):CONTENTFUL_SPACE_ID= CONTENTFUL_ACCESS_TOKEN= CONTENTFUL_ENVIRONMENT=master - In the GitHub repo settings, add the same three values as Actions secrets (
CONTENTFUL_SPACE_ID,CONTENTFUL_ACCESS_TOKEN,CONTENTFUL_ENVIRONMENT), and set Pages source to "GitHub Actions".
Without a .env (or those secrets in CI), the build still succeeds — it just generates zero Contentful-backed pages, logging a warning instead of failing.
To build against draft (unpublished) content instead of only published entries, set CONTENTFUL_PREVIEW=true and CONTENTFUL_PREVIEW_ACCESS_TOKEN (a separate token from your CDA CONTENTFUL_ACCESS_TOKEN, issued in Contentful under the same space). Don't set these in the production deploy workflow's secrets — doing so publishes draft/unpublished content to the live public site.
The build reads the contentful_collections list in _config.yml and generates one page per Contentful entry per collection — this is the extension point for adapting the template to a new site:
contentful_collections:
- content_type: post
layout: single
dir: posts
home: true
order: "-fields.publishDate"
- content_type: page
layout: single
dir: ""
nav: true| Key | Required | Meaning |
|---|---|---|
content_type |
yes | The Contentful content type id to fetch. |
layout |
yes | Which layout in _layouts/ renders the page. |
dir |
yes | URL path prefix; posts builds /posts/<slug>/, "" builds pages at the site root (/<slug>/). With contentful_locales configured, dir can be a Hash keyed by locale code ({en-US: products, nb-NO: produkter}) instead of one string, if the path segment itself should be translated too, not just prefixed with the locale — see Locales below. |
nav |
no | true lists this collection's pages in the site nav (see _layouts/default.html). |
home |
no | true groups this collection's pages into a section on the homepage (see index.html). |
label |
no | Homepage section heading for this collection, if home is set. A plain string, or a Hash keyed by locale code (mirroring dir) for a translated heading per locale. Defaults to a humanized content_type (e.g. newsArticle → "News Article") for any locale without one. |
order |
no | A Contentful CDA order value (e.g. fields.publishDate or -fields.publishDate) controlling fetch/display order; defaults to -sys.updatedAt. |
body_field |
no | The field to render as page content, if not body. A field literally named content collides with Jekyll's own reserved page.content/{{ content }} and is otherwise unreachable, so this is the only way to use such a field as the page body. |
image_field |
no | The field to use as page.social_image (the Open Graph/Twitter Card image — see SEO below), if not image. |
post and page both use _layouts/single.html, a small shared layout (layout: default + {% include article.html %}) — give a collection its own layout file only once it needs different markup.
A top-level contentful_entry_depth setting (default 2) controls how many levels of linked entries get fully resolved before degrading to a stub — raise it if your content model has deep reference chains (e.g. a menu linking pages that each link their own sub-pages).
Every content type needs a slug field and a body field (body by default — see body_field above); post additionally uses publishDate for ordering. title is never a literal field requirement: it always comes from that content type's Contentful-configured "Entry title" field (set per content type in Contentful's UI), whatever it's actually named — so a content type titled by, say, headline or eventName works without any template changes.
An entry's slug field is sanitized into a URL-safe form (lowercased, spaces/punctuation replaced) if it isn't one already, with a build warning logged when this happens — so messy slugs in Contentful stay visible without breaking the build.
To add a new content type (e.g. a "product" or "event"), add an entry to contentful_collections and a matching layout — no changes to the generator plugin are needed.
Every field on an entry (other than its body field, which becomes content/{{ content }}) is available on page by its snake_cased field ID — a Contentful field coverImage becomes page.cover_image, with no config or Ruby changes needed to reference a new field. A few field types need a bit more than {{ page.some_field }}:
page.title— always available, regardless of what the title field is actually called in Contentful (see above).- Asset fields (an image, file, etc.) become
{ "url", "content_type", "title", "description", "width", "height" }(width/heightare only set for images,nilotherwise):<img src="{{ page.cover_image.url }}" alt="{{ page.cover_image.title }}" width="{{ page.cover_image.width }}" height="{{ page.cover_image.height }}">— including dimensions avoids layout shift while the image loads. - Reference fields (a link to another entry) become that entry's own fields, flattened the same way —
{{ page.author.title }},{{ page.author.email }}, etc. work directly, one level deep by default (seecontentful_entry_depthabove for content models with deeper reference chains). - Array fields (multiple values, or multiple references) become a Liquid array — loop with
{% for tag in page.tags %}{{ tag }}{% endfor %}, or{% for related in page.related_posts %}{{ related.title }}{% endfor %}for an array of references. - A reference field whose linked entry has its own Rich Text field does not get that field pre-rendered to HTML (only a page's own top-level body field is) — it arrives as a raw Rich Text document, not usable directly in a layout without extra work.
_layouts/single.html + _includes/article.html is the simplest example: a title, an optional date (only shown when the entry actually has one), and the rendered body. Copy that pattern for a new content type's layout, or write something entirely different — Jekyll layouts are plain Liquid/HTML.
Some content types are only ever referenced from other entries and never need a page/URL of their own (e.g. an "author" or "manufacturer" linked from posts/products). List those under contentful_data_collections instead, and they're fetched into site.data.<name> rather than generating pages:
contentful_data_collections:
- content_type: author
name: authorsFor a single-locale site, do nothing — there's no locale-related config at all by default. To build a translated site, list the Contentful locale codes to build (Contentful: Settings → Locales) under contentful_locales, first = primary:
contentful_locales:
- en-US
- nb-NOBy default a non-primary locale's URL/site.data prefix is its full Contentful code (/nb-NO/...). For a shorter or different prefix, use the Hash form instead of a bare code:
contentful_locales:
- en-US
- code: nb-NO
prefix: "no"Quote a prefix if it could look like a YAML boolean — no, yes, on, off (any case) parse as true/false, not the string you meant, if left bare. Unquoted prefix: no silently becomes prefix: false; the build catches this specific case and fails with a clear message rather than silently falling back to the full locale code.
The primary locale's URLs and site.data keys are unprefixed, exactly as if contentful_locales weren't set at all — adding a second locale to an existing single-locale site never changes its existing URLs (as long as the locale you're already using stays first in the list). This includes a single-entry list too: contentful_locales: [nb-NO] always fetches nb-NO explicitly, even if your Contentful space's own default locale is something else — only omitting contentful_locales entirely skips sending a locale param and relies on the space's default. Every other locale gets its own /<locale>/... URL prefix (/nb-NO/products/vin/) and its own suffixed site.data.<name>_<locale> key (site.data.authors_nb_no), fetched and rendered entirely separately — a linked entry resolves to that locale's own translated fields, not the primary locale's. The homepage (index.html) only ever shows the primary locale's content; a fully localized homepage needs its own index.html per locale (e.g. nb-NO/index.html), following the same pattern — including locale: nb-NO in that file's own front matter, so its nav renders correctly instead of silently behaving like a primary-locale page. The nav in _layouts/default.html automatically scopes itself to whichever locale the page currently being rendered belongs to.
The locale prefix only affects the URL segment each_locale adds — the dir segment itself (e.g. "products") stays whatever you configured, in every locale, unless you make it a per-locale Hash (see the dir row above): dir: products gives /products/vin/ (primary) and /nb-NO/products/vin/ (secondary) — same word, just prefixed. dir: {en-US: products, nb-NO: produkter} gives /products/vin/ and /nb-NO/produkter/vin/ — a genuinely translated path, not just a translated prefix.
A locale code has to already exist in your Contentful space (Settings → Locales) before you can build against it — the CDA rejects an unrecognized code outright ("Unknown locale") rather than falling back to anything, so add the locale in Contentful first, then add it to contentful_locales.
Set the top-level lang in _config.yml (defaults to en) to your primary locale's language, for <html lang> on primary-locale pages — every other configured locale's pages get their own correct <html lang> automatically from their Contentful locale code.
Every page automatically gets a canonical link, a meta description, and Open Graph/Twitter Card tags (via _includes/seo.html, included from _layouts/default.html) — no per-page setup needed:
- The description falls back to the page's own rendered content (stripped and truncated) if the content type has no
descriptionfield. - The social preview image comes from
page.social_image, which defaults to a field literally namedimageon each collection — setimage_fieldon acontentful_collectionsentry (see above) if the real field is named something else (e.g.coverImage). og:localeis derived from the page's locale (or the top-levellangsetting for the primary locale).
sitemap.xml and robots.txt are generated at the site root from every page's .url, so they stay correct automatically as content is added or removed — no extra config. A custom 404.html is included too (required by GitHub Pages to show something other than GitHub's own default 404 page).
All of this needs url set correctly in _config.yml (see the next section) — without a real absolute URL, canonical links, Open Graph tags, and the sitemap all render broken/relative links.
This repo is a GitHub template repo. To start a new site from it:
- Click "Use this template" on GitHub (or
gh repo create <new-repo> --template pettersuul/github-pages-contentful) to create your new repo. - Update
title/description/urlin_config.yml(the last one to the site's real deployed URL, e.g.https://<user>.github.io— canonical links, Open Graph tags, and the sitemap all need a real absolute URL to be correct), and adjustcontentful_collectionsto match the new site's content types. - Point it at a new Contentful space via
.envlocally and the three Actions secrets in the new repo (see Setup above).
bundle exec jekyll serve— run the local dev server athttp://localhost:4000(reads.envvia thedotenvgem)bundle exec jekyll build— build the static site into_site/bundle exec jekyll build --trace— build with full backtraces on plugin errors