A working Indiekit + Jekyll site. Click Use this template, and you have a site that publishes posts and serves them at the URLs Indiekit reports.
bundle install
npm install
cp .env.example .env # set SECRET to a long random string
npm start # Indiekit on :3000Visit http://localhost:3000/auth/new-password, set a password, copy the generated value
into PASSWORD_SECRET, and restart. Then in a second terminal:
npm run build && npm run serve # your site on :8080Publish a post in Indiekit, then re-run npm run build and open the URL it gave you.
Nothing watches for you: npm run build is one-shot, so a freshly published post 404s
until you rebuild.
When you deploy, point indiekit: in _config.yml at where Indiekit actually runs. The
authorization_endpoint, token_endpoint and micropub <link rel> tags are built from
it, and the default is http://localhost:3000 — which on a public site points every
visitor at their own machine, so IndieAuth works for nobody. Jekyll has no environment-variable
interpolation in _config.yml, so there are two ways to set it: edit _config.yml directly
(simplest for one deployment), or layer a second config file at build time:
# _config.production.yml
indiekit: https://indiekit.example.combundle exec jekyll build --config _config.yml,_config.production.ymlDefault — dateless. indiekit.config.js sets path and url to the same shape for
every post type, so _notes/hello.md is reported and served as /notes/hello. Change the
shape there and both halves stay in step.
Every collection in _config.yml needs both:
notes: { output: true, permalink: /notes/:name/ }output: true makes Jekyll render the collection at all — without it your posts are written
to disk and never become pages. The trailing-slash permalink makes Jekyll write
notes/hello/index.html instead of notes/hello.html; without it the URL Indiekit reports
works on GitHub Pages and Netlify, which fall back to .html, and 404s on a plain static
host. If you add a post type, add its collection here too.
Dated URLs — opt-in. If you want /notes/2026/08/22/hello, leave the file paths alone
and change two things. First the advertised URL in indiekit.config.js:
url: `${collection}/{yyyy}/{MM}/{dd}/{slug}`, // path stays _${collection}/{slug}.mdthen the collection's permalink in _config.yml:
notes: { output: true, permalink: /notes/:year/:month/:day/:title/ }One entry per collection. Jekyll takes the date from each post's front matter, which
preset-jekyll already writes as date, so the filenames stay dateless and nothing needs
renaming.
Verified against Jekyll 4.4.1: _notes/hello.md with a date builds to
_site/notes/2026/08/24/hello/index.html.
article needs one extra line. Unlike the other five it can also live in Jekyll's built-in
_posts collection, and stock Jekyll renders that to a flat /2026/08/23/hello.html — an
extension the preset does not advertise. If you move articles there, set the site-wide
permalink too:
permalink: /:year/:month/:day/:title/See indiekit#902 for why the preset's own defaults do not resolve on their own.
The starters are the same idea per generator, but Jekyll is not Eleventy or Hugo and this one does not pretend otherwise. What is genuinely different here:
Every collection needs both output: true and a trailing-slash permalink. Hugo and
Eleventy write directory-style output (slug/index.html) natively; Jekyll defaults to a flat
slug.html per collection entry unless the permalink says otherwise.
The store is the repository root, with _-prefixed collection directories (_notes/,
_articles/, …) and media/ beside them — Jekyll's own convention for collections. The
Eleventy sibling points its store at content/; the Hugo sibling uses the root too but splits
content/ and static/.
Front matter arrives snake_cased. preset-jekyll runs snakecase-keys deeply, so
Micropub's bookmark-of is page.bookmark_of in Liquid, and in-reply-to is
page.in_reply_to; name becomes title and published becomes date. The Hugo sibling
gets camelCase (.Params.bookmarkOf).
photo is a sequence of mappings, not strings. It is written as
photo: [{ url: /media/photos/pixel.gif }], so {{ image }} alone renders a Ruby hash dump
({"url"=>...}) rather than a URL — the layout has to read image.url.
jekyll serve hides the permalink requirement above. WEBrick resolves extension-less
paths, and so does http-server (which this repo's npm run serve uses) — its .html
fallback is on by default and cannot be turned off. That is why test/all-types.mjs checks
the built output on disk rather than trusting an HTTP status code to catch a missing
permalink.
Ruby, not Node, builds the site. bundle install is a prerequisite the Node-only
Eleventy and Hugo siblings do not have, and a build must run jekyll clean first or deleted
posts keep being served — Jekyll does not prune its own destination on a plain build.
Stock Jekyll generates nothing extra. With only gem "jekyll" and no theme there is no
feed and no tag or category pages — those come from jekyll-feed and from minima. The Hugo
sibling emits index.xml, tags/ and categories/ natively and keeps them.
Dated URLs need a permalink per collection, and one more for article. All three
starters can do dated URLs; Jekyll is the only one where a post type (article) can also
land in a built-in collection (_posts) that needs its own site-wide permalink. See the
URLs section.
No feeds, pagination, category or tag pages, 404 page or theming, and deliberately no
Jekyll theme — the layout in _layouts/ is the point. It is a floor to build on. Add what
you need.
Adding an entry to publication.postTypes in indiekit.config.js is not enough on its own.
Without the matching post-type plugin also listed in plugins, Indiekit silently drops the
type — no error, it just does not exist (lib/post-types.js only keeps types that have a
properties object, which only a post-type plugin supplies).
For Jekyll, add the second half too: the collection in _config.yml, with both output: true
and a trailing-slash permalink — without it, the new type's posts are written to disk and
never rendered.
npm run smoke # publish → resolve → marker → index → delete → 404
npm run test:types # all six default post types, plus media uploadBoth run against built output, not jekyll serve — WEBrick resolves extension-less
paths, so the dev server hides a missing permalink that breaks on a real static host.
Do not assume the static server is what catches that, though: http-server, which
npm run serve uses, has its own .html fallback on by default, so it answers
/notes/hello with 200 whether Jekyll wrote notes/hello/index.html or a flat
notes/hello.html. No HTTP assertion here can tell those apart. What actually catches a
missing permalink is test/all-types.mjs, which inspects the built files on disk and
fails if any collection rendered flat.
Everything above works with no database at all. Without MongoDB, npm run smoke prints
skip deleted post returns 404 — requires MongoDB and reports 5/5 assertions passed, 1 skipped — that skip is expected, not a broken template. Deleting a published post through
Indiekit's Micropub delete action needs a database to track it
(indiekit#904).
To run it, point Indiekit at a database and set the same MONGO_URL for the tests —
that variable is what test/smoke.mjs branches on. With it set, npm run smoke
reports 6/6 assertions passed.
If you need to remove a post, delete its Markdown file under the _-prefixed collection
directory and rebuild — npm run build runs jekyll clean first, so the stale page is always
removed, database or not.