Content organisation
Every page on a Baseline site has two layers behind it.
One is structural: where the page lives, what layout wraps it, whether it shows up in nav. The other is the content's own metadata: title, description, date. They want different homes.
The pattern is to keep structure in a sibling *.11tydata.js file and content metadata in the page's front matter. This isn't a Baseline rule. It's a way of using Eleventy's data cascade that scales as your sections grow.
STRUCTURE (*.11tydata.js) -> section, type, layout, permalink
CONTENT (front matter) -> title, slug, description, date
Structure is what every page in a section shares. Content is what makes one page different from the next. Mix them and the section gets harder to reason about every time you add a page.
The structural side
Drop a sibling data file next to the section's pages. Eleventy applies its values as defaults to every page in the directory, and per-page front matter can still override anything specific.
layout is read by Eleventy. draft is read by Baseline's drafts preprocessor. section and type are read by Baseline into page context, so the content graph and your templates see them through the same surface.
section is a hierarchical path written as an array, even when there's only one level. One-level is ['blog']. Nested is ['blog', '2026']. Nest as deep as your site needs. The shape mirrors Jekyll's categories: same job, same array form.
// src/content/blog/blog.11tydata.js
export default {
section: ['blog'], // hierarchical path; nest as ['blog', '2026', 'q1']
type: 'post', // free-form classifier for collections and templates
draft: false, // overridden by individual pages when needed
layout: 'layouts/post.njk' // cascades to every page in the folder
// permalink (covered below)
};
Keep the ones you actually use; empty conventions rot.
The content side
Front matter stays small and per-page:
---
title: 'A short title'
slug: 'a-short-title'
description: 'One-sentence description of this specific page.'
date: 2026-05-01
---
Four fields, every page. Title and description for the head module and link previews. Slug for the URL and for wikilinks to find the page. Date for ordering and for the sitemap's lastmod.
Per-page exceptions go here too. If a single post needs a different layout, declare layout in its front matter; the cascade picks the more specific value.
The two layers meet at the permalink
The cleanest place to see the split is the permalink function. It reads slug from front matter and the structural keys from the data file, and composes the URL from both.
A page in src/content/blog/ with slug: 'first-post' and section: ['blog'] resolves to /blog/first-post/.
Extend section to ['blog', '2026'] and every page in the folder gets /blog/2026/<slug>/ without each post having to spell out its own path. The array is the path.
// src/content/blog/blog.11tydata.js
export default {
// permalink (covered below)
permalink: function (data) {
const { slug, page, section } = data;
// Don't try to render the data file itself as a page.
if (page.inputPath.includes('11tydata.js')) {
return false;
}
// Front-matter slug wins; otherwise fall back to the file's own slug.
const slugified = slug ? this.slugify(slug) : page.fileSlug;
// Compose /<section[0]>/<section[1]>/.../<slug>/.
const parts = (section ?? []).map((part) => this.slugify(part));
parts.push(slugified);
// Leading slash anchors to the site root; trailing slash is the project convention.
return '/' + parts.join('/') + '/';
}
};
Adding a language prefix
For a multilingual site, the permalink needs one more step: prefix the URL with the language code, except for the default language which sits at the site root.
The signal comes from two places already in the cascade. data.lang is set per language tree (typically in en.11tydata.js, nl.11tydata.js, and so on). data.settings.defaultLanguage lives in _data/settings.js and names the language that owns the root. What those two keys do beyond the permalink is the multilang module's subject.
Drop the data file one level up, at the top of your content tree, so its values cascade to every section underneath:
// src/content/content.11tydata.js
export default {
permalink: function (data) {
// Don't try to render the data file itself as a page.
if (data.page.inputPath.includes('11tydata.js')) return false;
// Front-matter slug wins; otherwise fall back to the file's own slug.
const slug = data.slug ? this.slugify(data.slug) : data.page.fileSlug;
// Prefix non-default languages; default language and single-language sites sit at root.
const isDefaultLang = !data.lang || data.lang === data.settings?.defaultLanguage;
const prefix = isDefaultLang ? '' : `/${data.lang}`;
// Compose /<section[0]>/<section[1]>/.../<slug>/, with optional language prefix.
const sections = (data.section ?? []).map((s) => this.slugify(s));
return `${prefix}/${[...sections, slug].join('/')}/`;
}
};
A page with lang: 'en' on a site whose defaultLanguage is 'en' resolves to /blog/first-post/. The same page with lang: 'nl' resolves to /nl/blog/first-post/. A single-language site leaves lang unset and never sees a prefix.
The optional chaining on data.settings?.defaultLanguage keeps the check safe when settings or defaultLanguage isn't defined, which is the single-language case.
Worth knowing
sectionis always an array. Even a one-level section is written as['blog']. The shape teaches the path-like nature of the field and lets you nest deeper without restructuring later.- Front matter overrides the data file. Eleventy resolves to the more specific value, so a single page can opt out of any default by declaring it in front matter.
- The data file isn't a page. The
inputPath.includes('11tydata.js')check returnsfalsefrom the permalink. Baseline already adds**/*.11tydata.jsto Eleventy's ignores, so the check rarely fires; it is cheap insurance that keeps the function correct on its own terms, and it travels if you lift it into a project without Baseline. - Paths use a leading slash. Without it, the permalink resolves relative to the input subdirectory rather than the site root.
- A permalink function cannot read an
eleventyComputedvalue. Computed data resolves after the cascade the permalink function runs in, so anything fromeleventyComputedarrives asundefined. Build a URL from it and the function warns, returnsfalse, and the template's own permalink wins instead, so the site looks right and only the console disagrees. The warning names the template rather than the data file that produced the value, which is what makes this one expensive to find. Read the raw front-matter and structural keys, as the function above does, and compute inside the permalink rather than before it.
Opting out
A page can exist and render without showing up in indexes. Two keys cover the common cases, one Baseline and one Eleventy-native, and either works from front matter or the data file:
---
baselineExcludeFromGraph: true # keep this page out of the content graph
eleventyExcludeFromCollections: true # keep this page out of all Eleventy collections
---
One is Baseline's, one is Eleventy's, and they are not the same scope: a page can sit outside every collection and still be in the graph. Which is which, and the third flag for synthetic templates, is on Content graph.
The organisational point is that both belong wherever the decision belongs. A single odd page declares it in front matter; a whole folder of them declares it once in the data file.
See also
- Project structure for the project-shape conventions this pattern slots into.
- Page context reference for what Baseline reads off each page at cascade time.