---
title: 'Eleventy Base Blog'
description: 'Convert the official eleventy-base-blog starter to Baseline: what moves, what gets deleted, what comes back, and the failures a green build hides.'
slug: 'integrate-eleventy-base-blog'
type: 'article'
date: 2026-08-25T00:00:00.000Z
lang: 'en'
url: 'https://www.eleventy-baseline.dev/docs/integrations/integrate-eleventy-base-blog/'
---

{% if part.index == 0 %}
Adding Baseline to `eleventy-base-blog` is mostly subtraction. The config goes from 168 lines to 84, sixteen registrations to nine, and `dependencies` ends up holding exactly one package. This guide walks the whole conversion in the order it was actually done.

Written against `@apleasantview/eleventy-plugin-baseline@0.1.0-next.45` and `eleventy-base-blog` v9.0.0. The reference implementation is the [`0.1.0-next.45` branch of eleventy-baseline-blog](https://github.com/apleasantview/eleventy-baseline-blog/tree/0.1.0-next.45), whose commits follow the steps below one for one.

---

## Before you start: the base blog is a demo

Most of what looks awkward in `eleventy-base-blog` is deliberate demonstration rather than oversight. `dir.includes: "../_includes"` proves directories are configurable. The per-page `addBundle` calls are a shop window for `eleventy-plugin-bundle`. There is `---js` front matter because JS front matter is a thing Eleventy can do, and the fifth post is `draft: true` because drafts need demonstrating.

"Here is what Eleventy can do" is a coherent goal. It is a different job from "here is a blog you can start writing in", and most people clone it for the second. That is worth knowing before the config page, where a lot comes out at once. You are converting a showcase into an opinionated project, and the things you delete were exhibits.

---

## Prerequisites

- `eleventy-base-blog` cloned and running (`npm install`, `npm start`).
- Node 22 or newer. Baseline requires it, and the starter advertises 18.
- Somewhere to set an environment variable on your host, for `BASELINE_URL`.

---

## The shape of it

Five pages after this one, listed at the foot of each. A page is a sitting, and the site does not build correctly again until you reach the end of the third.

1. **Prepare the project.** Gitignore, install, scripts. Nothing structural, and it saves you from stale output later.
2. **Move it under `src/`.** Directories, the settings file, standalone pages, then slugs and permalinks. The build is broken for most of this, expectedly.
3. **Rebuild the config.** Park the old one, write a nine-line replacement, then let the build tell you what to put back. This is the page the whole guide is arranged around.
4. **Wire up the head.** Swap in `<baseline-head>`, then finish the config with the feed, syntax highlighting and images.
5. **Tidy up**, then check the output. Rehome the per-page CSS, fix the links, give every page a description, delete `metadata.js`.

Nothing on the next page changes how the site builds. It is the housekeeping that makes everything after it less painful.
{% endif %}
{% if part.index == 1 %}
## Add `dist/` and `temp/` to `.gitignore`

```text
dist/
temp/
```

The starter ignores `_site/`, which is the only output directory it knows about. Baseline's output is `dist/`, so until you do this every build is stageable. Nothing warns you. Do it before anything can generate output rather than at the point the output moves.

`temp/` is where the old config goes when you park it, along with anything else you want out of the way while you work. Parked files are working material, not deliverable.

## Install Baseline

```bash
npm install @apleasantview/eleventy-plugin-baseline@0.1.0-next.45 --save-exact
```

Pin exactly, no caret. This is a fast-moving prerelease line, and you want version changes to be deliberate commits rather than silent lockfile drift.

Then two helpers for the scripts in the next step:

```bash
npm install --save-dev rimraf npm-run-all
```

`rimraf` empties the output directory, `npm-run-all` chains the scripts together. `cross-env` is already a dependency of the starter, so it needs nothing.

## Rewrite the scripts

```json
"start": "npm-run-all clean dev",
"clean": "rimraf dist/",
"dev": "eleventy --serve",
"build:eleventy": "cross-env ELEVENTY_ENV=production eleventy",
"build": "npm-run-all clean build:*",
"dryrun": "eleventy --dryrun"
```

`clean` earns its keep from the first build onwards. Stale output in `dist/` is a convincing liar. There is a brief inconsistency here: until you rewrite the config the output directory is still `_site/`, so `clean` is deleting a directory nothing writes to yet.

Call the binary directly rather than through `npx`. It is on `node_modules/.bin` inside a script either way, and `npx` breaks under pnpm.

Keep `debug`, `debugstart` and `benchmark` if you use them. They are orthogonal to Baseline.

Bump `engines.node` to `>=22` while you are in `package.json`. Advertising 18 is a promise the project can no longer keep.

**No `.env`.** Development falls back to localhost in the settings file you write on the next page, and production takes `BASELINE_URL` from your host's environment. Nothing in the starter or the plugin loads a `.env` file, so adding one means adding a loader for no gain. [[deployment-checks | Deployment checks]] covers the route through a `.env` if you would rather have one, including where the import has to live.

The project is ready and the site still builds exactly as it did. The next page is where that stops being true.
{% endif %}
{% if part.index == 2 %}
## Move the directories under `src/`

```bash
mkdir -p src/assets
mv _data src/_data
mv _includes src/_includes
mv content src/content
mv css src/assets/css
mv public src/static
```

Plain `mv` is fine. Git infers the renames from content similarity when you stage them.

The on-disk folder is `static/`, while the virtual directory key Baseline registers is `public`. The [[config-export | config-export reference]] covers that split.

`_config/` stays where it is. Nothing in Baseline's contract has an opinion about it.

**The build is broken from here until you rewrite the config.** `dir` still points at `content/`, `../_includes` and `../_data`, all of which just moved. Expected, and not worth debugging.

## Write the settings file

Create `src/_data/settings.js` from `metadata.js`:

```js
export default {
	title: 'Eleventy Base Blog v9',
	tagline: 'A blog built with Eleventy Baseline',
	description: 'I am writing about my experiences as a naval navel-gazer.',

	url: process.env.BASELINE_URL || 'http://localhost:8080',

	noindex: false,
	defaultLocale: 'en',

	head: {
		link: [{ rel: 'stylesheet', href: '/assets/css/index.css' }]
	},

	// Used by the feed and by templates.
	author: {
		name: 'Your Name Here',
		email: 'youremailaddress@example.com',
		url: 'https://example.com/about-me/'
	}
};
```

This has to exist before the config swap, because the config imports it. Living in `src/_data/` also hands it to every template as `settings`.

`url` is the one setting Baseline warns about when it is missing. It anchors canonicals, sitemap entries and the structured-data graph, so it must be an absolute URL. The full shape is on [[site-settings | site settings]].

**Leave `metadata.js` where it is.** The feed config and every layout still read `metadata.*`. Retiring it comes last, once its consumers have moved.

## Move standalone pages into `pages/`

```bash
mkdir -p src/content/pages
mv src/content/about.md src/content/pages/
mv src/content/404.md src/content/pages/
```

Standalone pages only. `index.njk`, `blog.njk`, `tags.njk` and `tag-pages.njk` stay where they are, along with `blog/` and `feed/`.

The folder name is free, since Baseline derives URLs from `slug` rather than from path. `pages/` matches the tutorial.

Leave the front matter alone for now. That is the next step, and it is the step that makes this one correct.

## Give every page a slug, a permalink rule and a title

Without this, everything under `src/content/` lands at `/content/…`, because the input directory is now `src/`.

Two directory data files, one rule each:

| File | Rule | Result |
| --- | --- | --- |
| `content.11tydata.js` | `slug` → `/slug/` | root pages, and `pages/` by cascade |
| `content/blog/blog.11tydata.js` | `slug` → `/blog/slug/` | posts keep their prefix |

Create `src/content/content.11tydata.js`:

```js
export default {
	layout: 'layouts/base.njk',
	permalink: function ({ slug, page }) {
		if (!slug) {
			console.warn(`Warning: No slug found for ${page.inputPath}`);
			return false;
		}

		try {
			return `/${this.slugify(slug)}/`;
		} catch (error) {
			console.error(`Error generating permalink for ${page.inputPath}:`, error);
			return false;
		}
	}
};
```

Posts want a segment of their own, so `src/content/blog/blog.11tydata.js` overrides it with the same shape and a different return:

```js
export default {
	tags: ['posts'],
	layout: 'layouts/post.njk',
	permalink: function ({ slug, page }) {
		if (!slug) {
			console.warn(`Warning: No slug found for ${page.inputPath}`);
			return false;
		}

		try {
			return `/blog/${this.slugify(slug)}/`;
		} catch (error) {
			console.error(`Error generating permalink for ${page.inputPath}:`, error);
			return false;
		}
	}
};
```

Keep the guard. A missing slug otherwise gives you a page at an address you did not choose, on a build that exits zero.

Two files rather than three, and `layout` at the top set to `base.njk`: both ride the same cascade, which [[content-organisation | content organisation]] covers. `blog/` overrides layout and permalink, the homepage names `layouts/home.njk` itself so the message box stays on one page, and everything else inherits.

Write slugs explicitly in front matter. **A directory-data `permalink` function cannot read an `eleventyComputed` value**, because computed data resolves after the cascade the function runs in. It gets `undefined`, warns, and returns `false`; the template's own permalink wins later, so the output looks right and only the console complains. The warning names the template rather than the data file that produced it.

Three exceptions:

- `index.njk`: `permalink: "/"` and `layout: "layouts/home.njk"`.
- `404.md`: `permalink: 404.html`.
- `tag-pages.njk`: permalink as a plain front matter **string**, not a function and not a computed value:

  {% raw %}

  ```js
  const permalink = "/tags/{{ tag | slugify }}/";
  ```

  {% endraw %}

  Plain front matter wins the cascade merge outright, so the directory function is never called. Eleventy renders permalink strings as templates, which is what makes this work.

Add a `title` to `index.njk`, `blog.njk`, `tags.njk`, `about.md` and `404.md` in the same pass. None of them has one. It does not matter today, and it matters when you swap the head: the head module builds `<title>`, the Open Graph title and the JSON-LD node from `title`, and the layout's `{{ title or metadata.title }}` fallback disappears along with the old head.

Every file is now where Baseline expects it, and the build is still broken, because the config is still describing the old layout. The next page fixes that by throwing it away.
{% endif %}
{% if part.index == 3 %}
## Park the old config and write a minimal one

Move `eleventy.config.js` out of the way, then write a new one from scratch:

```js
import baseline, { config as baselineConfig } from '@apleasantview/eleventy-plugin-baseline';
import settings from './src/_data/settings.js';

export default async function (eleventyConfig) {
	await eleventyConfig.addPlugin(baseline(settings));
}

export const config = baselineConfig;
```

Nine lines. Not a diff, and not the old file with most of it commented out. The parked copy is your reference; a live config full of dead code is harder to read than the file next to it.

This is the step the rest of the guide is arranged around. Patching the old config in place is what let the previous version of this guide rot: advice about retargeting `addBundle` survived for months after the assets module made it wrong, because nobody was made to justify each registration.

`export const config = baselineConfig` replaces the starter's `templateFormats` as well. Baseline's `['html', 'njk', 'md']` drops `liquid` and `11ty.js`, which nothing in the starter uses. Keep `njk`: several of Baseline's virtual templates are Nunjucks.

## Run the build and fix what it names

```bash
npm start
```

The build is the checklist. Eleventy reports config-level failures before it renders anything, so missing plugins surface first and missing filters and shortcodes second, which is a usable teaching order that falls out for free.

Two deletions first:

1. **Delete `src/content/sitemap.xml.njk`.** The sitemap module owns that now.
2. **Delete the per-page bundle markup** in `base.njk` and `post.njk`, along with both `addBundle` calls. The assets module owns `dist/assets/`, and two things writing there is one too many. Step 12 rehomes what the bundles carried.

Then what comes back, in the order it sits in the finished file:

3. **The drafts preprocessor**, if you want it. A choice rather than a fix, see below.
4. **`_config/filters.js`.** `readableDate`, `htmlDateString`, `filterTagList`, `sortAlphabetically`, `getKeys` and `min` are all still load-bearing in templates. Overlap with Baseline's own filters is not a reason to drop them.
5. **The `currentBuildDate` shortcode.**
6. **`@11ty/eleventy-navigation`.**

The build will name them in its own order rather than this one, since it reports config-level failures before template-level ones. Adding them in file order keeps the config readable as it grows, and nothing here depends on registration order.

The whole file at the end of this step, so you can check yours against it:

```js
import baseline, { config as baselineConfig } from '@apleasantview/eleventy-plugin-baseline';
import settings from './src/_data/settings.js';

import pluginFilters from './_config/filters.js';
import pluginNavigation from '@11ty/eleventy-navigation';

export default async function (eleventyConfig) {
	await eleventyConfig.addPlugin(baseline(settings));

	// Drafts, see also _data/eleventyDataSchema.js
	eleventyConfig.addPreprocessor('drafts', '*', (data, content) => {
		if (data.draft) {
			data.title = `${data.title} (draft)`;
		}

		if (data.draft && process.env.ELEVENTY_RUN_MODE === 'build') {
			return false;
		}
	});

	eleventyConfig.addPlugin(pluginFilters);

	eleventyConfig.addShortcode('currentBuildDate', () => {
		return new Date().toISOString();
	});

	eleventyConfig.addPlugin(pluginNavigation);
}

export const config = baselineConfig;
```

Twenty-two lines against the original's 168, and that is the low-water mark rather than the finished state. The feed, syntax highlighting and the image transform come back on the next page, taking it to 84.

**On drafts.** Baseline registers its own drafts preprocessor and guards the registration, and Eleventy keys preprocessors by name, so yours wins whichever order they load in. It is not a supersession. Baseline covers build-time filtering only, while the starter's version also appends `(draft)` to the title while serving, which is a genuinely useful writing affordance. Drop it and that marker disappears on a green build with no warning.

The site builds again, and it is still wearing the starter's hand-written `<head>`. That is the next page, and it is the one that looks alarming while it works.
{% endif %}
{% if part.index == 4 %}
## Swap the head, then put your stylesheet back

In `base.njk`, replace the entire hand-written `<head>` with:

{% raw %}

```html
<baseline-head></baseline-head>
```

{% endraw %}

No wrapping `<head>`. The placeholder **is** the head, and Baseline emits the opening and closing tags itself.

**Your site will render completely unstyled at this point.** The `<link>` to your stylesheet was inside the head you just deleted, and you cannot put one inside `<baseline-head>`. It comes back through settings, which is what `settings.head.link` in the settings file is for. Alarming, expected, and the single most disorienting moment in this conversion.

Drop the `<heading-anchors>` wrapper too, and the `@zachleat/heading-anchors` dependency with it. Baseline's auto heading IDs cover the ID generation; the visible anchor links are the actual trade. Worth doing deliberately rather than leaving: the element's script was inlined in the old head, so swapping in `<baseline-head>` leaves an inert custom element wrapping your content, markup that looks functional and does nothing.

Then make `settings.js` the only place the site states its identity. `base.njk` moves from `metadata.language` and `metadata.title` to `settings.defaultLocale` and `settings.title`, and the instruction list comes out of `home.njk`.

Verify this one in the output rather than by exit code. The homepage should now emit charset, viewport, a composed `<title>`, description, robots, canonical, Open Graph, a Twitter card, and a JSON-LD `@graph` carrying `WebSite`, `Organization` and `WebPage` nodes. The [[head | head module]] lists the full set.

## Finish the config

Three registrations, straight onto the end of the file. Each one fails silently if you skip it.

```js
eleventyConfig.addPassthroughCopy({
	'./src/content/feed/pretty-atom-feed.xsl': '/feed/pretty-atom-feed.xsl'
});

eleventyConfig.addPlugin(feedPlugin, {
	type: 'atom',
	outputPath: '/feed/feed.xml',
	stylesheet: 'pretty-atom-feed.xsl',
	templateData: {
		eleventyNavigation: { key: 'Feed', order: 4 }
	},
	collection: {
		name: 'posts',
		limit: 10
	},
	metadata: {
		language: settings.defaultLocale,
		title: settings.title,
		subtitle: settings.tagline,
		base: settings.url,
		author: settings.author
	}
});

eleventyConfig.addPlugin(pluginSyntaxHighlight, {
	preAttributes: { tabindex: 0 }
});

// Image optimization: https://www.11ty.dev/docs/plugins/image/#eleventy-transform
eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
	formats: ['avif', 'webp', 'auto'],
	failOnError: false,
	htmlOptions: {
		imgAttributes: {
			loading: 'lazy',
			decoding: 'async'
		}
	},
	sharpOptions: {
		animated: true
	}
});
```

With the three imports at the top:

```js
import { feedPlugin } from '@11ty/eleventy-plugin-rss';
import pluginSyntaxHighlight from '@11ty/eleventy-plugin-syntaxhighlight';
import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';
```

Two things in there are not just a paste-back. The XSL passthrough points at the file's new home under `src/content/feed/`, and the feed's `metadata` block reads `settings` rather than the hardcoded values it shipped with. Those values were a third source of truth for site identity, and they would put `example.com` URLs in a live feed.

The image transform is supported alongside Baseline's image shortcode rather than replaced by it, so no image references in your content need touching. Skip it and nothing emits images out of `src/content/` at all: the `<img>` tags point at files that were never built.

That is the config finished, at 84 lines against the original's 168, and nine registrations against sixteen.

Two things the deletions left behind are still outstanding, and so is the content. Both build cleanly, which is why they are last rather than forgotten.
{% endif %}
{% if part.index == 5 %}
## Rehome the per-page CSS

A loose end left behind by the deletions on the config page, and one that builds cleanly while being wrong.

The bundles carried `message-box.css` and `prism-diff.css`, neither of which is `@import`ed by `index.css`, plus a raw `<style>` include of the Prism theme in `post.njk` that now emits unbundled into every post body. Group them into a file under `src/assets/css/` and inline it with Baseline's filter, which does what the bundles did:

In `home.njk`, for the message box:

{% raw %}

```njk
{%- set messageBoxPath = _baseline.paths.assets ~ "/css/message-box.css" %}
{{ messageBoxPath | inlinePostCSS | safe }}
```

{% endraw %}

In `post.njk`, for syntax highlighting:

{% raw %}

```njk
{%- set prismPath = _baseline.paths.assets ~ "/css/prism.css" %}
{{ prismPath | inlinePostCSS | safe }}
```

{% endraw %}

`prism.css` is a new file, `src/assets/css/prism.css`, grouping the theme with the diff styles the starter already had:

```css
/* Syntax highlighting, inlined on blog posts only via `inlinePostCSS`. */
@import "prismjs/themes/prism-okaidia.css";
@import "./prism-diff.css";
```

`postcss-import` resolves the bare `node_modules` specifier, so the theme is imported by name rather than copied in. The [[assets | assets module]] covers the pipeline.

## Tidy the content, then delete `metadata.js`

**Input-path links.** The starter demonstrates `InputPathToUrlTransformPlugin` by linking between posts with `.md` paths, and without it those ship as literal `href="/blog/firstpost.md"`. Rather than restoring the plugin, use wikilinks:

```markdown
[[firstpost|First post]]
```

The overlap is partial and worth understanding: wikilinks are a markdown-it inline rule, so they cover Markdown body content only, not Nunjucks templates or front matter. Template-to-template links stay plain paths. A wikilink that misses renders as literal `[[slug]]` text rather than breaking the build, so mistakes are visible on the page.

**Descriptions.** Give every page a `description`. Before this, five pages in the starter share the site description, and `tag-pages.njk` needs a computed one so each tag page differs. Nothing warns about this: Baseline's fallback chain ends at extracting the first paragraph, which is not reliable enough to depend on. Front matter is the only dependable route.

Then delete `src/_data/metadata.js` and remove `@zachleat/heading-anchors`. Every consumer has moved, and `dependencies` is now one package:

```json
"dependencies": {
	"@apleasantview/eleventy-plugin-baseline": "0.1.0-next.45"
}
```

That is the conversion. What is left is finding out whether it worked, and the exit code will not tell you.

---

## What a green build will not tell you

Start the dev server and walk the site first:

```bash
npm start
```

Every page, a post with a code block in it, the tag pages, the feed. You are looking for the things a person would notice: styling that is missing, a layout on the wrong page, a link that goes nowhere. Drafts render in this mode and images are served from a dev endpoint, so a clean walk here does not mean the built site is clean.

Then build it for production, with the origin you actually deploy to:

```bash
BASELINE_URL=https://www.example.com/ npm run build
```

It will pass. The most useful thing this conversion taught is that Eleventy exits zero on a site that is quietly broken, so go and look at `dist/` rather than at the exit code:

- **Images.** Real `.avif` and `.webp` files under `dist/`, not `.11ty/image/` dev endpoints.
- **The stylesheet.** `dist/assets/css/index.css` exists, is linked, and is minified on a production build.
- **Inlined CSS.** Grep the output for `/* Error processing CSS */`. `inlinePostCSS` catches its own errors and emits that comment inside a `<style>` element rather than failing, so a wrong path is invisible.
- **Wikilinks.** No literal `[[` anywhere in `dist/`, and no `.md` hrefs.
- **Drafts.** The draft post absent from `dist/blog/`, from the feed and from the sitemap.
- **`BASELINE_URL`.** Nothing warns when it is unset, and a production build without it ships localhost in every canonical, `og:url` and JSON-LD node.

---

## Not covered here

**Subpath deploys.** `--pathprefix` and Baseline do not currently agree: `HtmlBasePlugin` rewrites relative links so stylesheets and nav pick up the prefix, while canonical, `og:url` and the JSON-LD graph are composed from `settings.url` and come out claiming the origin root. On a subpath deploy that is the one tag you most need correct. It is a plugin issue rather than an integration one, and [[deploy-under-a-subpath | deploying under a subpath]] is where it will be answered.

**The starter's deploy files.** `netlify.toml`, `vercel.json` and the GitHub Pages workflow sample all still point at `_site/`. They are outside this guide's job, which ends at a correct build, but they will fail after a green one, so they are worth a look before you deploy.

---

## See also

- [[config-export | Config export reference]]
- [[site-settings | Site settings]]
- [[assets | Assets module]]
- [[head | Head module]]
- [[content-helpers | Content helpers]]
{% endif %}
