How to Create a Blog Using 11ty
A step-by-step guide to building a blog with 11ty (Eleventy). Learn how to set up collections, write posts in Markdown, create layouts, generate SEO-friendly URLs, and ship structured data for better search visibility.
Originally I planned to build this portfolio / blog using rails. I mean I'm a rails developer after all. In fact I originally did build it on rails. I scaffolded the blog system, used devise for admin users, I even started building out the infrastructure to manage the work for my clients. Then one day I was building a static site for a client, and was using 11ty. I started wondering why I'm spending so much time building out a rails platform when 11ty can do everything I need. Do I really need to build out project management when I already use Notion? Do I really need a contact form when all I get out of it is spam? Then answer is a resounding NO! So I decided to keep things simple. A homepage and a blog.
This guide walks through building a complete 11ty blog from a fresh npm init: collections, custom filters, layouts, a listing page, SEO-friendly permalinks, Open Graph and Twitter Card meta tags, JSON-LD structured data, and a sitemap. Styling is intentionally left out — the goal here is the content pipeline, not the CSS. If you want to see the finished version, the source for this entire site lives at github.com/AlexKeyCodes/portfolio.
What is 11ty?
11ty (Eleventy) is a static site generator written in JavaScript. It takes Markdown, Nunjucks, Liquid, or a handful of other template formats and outputs plain HTML. No client-side runtime, no hydration, no framework — the output is the same kind of static HTML you'd get from hand-writing it, just with templating and layouts data baked in.
For a blog — or really any content-heavy site like docs, a portfolio, or a marketing site — 11ty is about as simple as it gets.
Part 1: Set Up a Fresh 11ty Project
Step 1: Initialize the Project
Create a new directory and initialize npm:
mkdir my-blog
cd my-blog
npm init -y
Install 11ty as a dev dependency:
npm install --save-dev @11ty/eleventy
Step 2: Add npm Scripts
Open package.json and add the following scripts:
{
"scripts": {
"build": "eleventy",
"serve": "eleventy --serve"
}
}
npm run buildproduces a static site indist/npm run servestarts a local dev server with hot reload athttp://localhost:8080
Step 3: Create the Directory Structure
11ty doesn't enforce a structure, but this is the layout I use across every project:
my-blog/
├── src/
│ ├── _data/
│ ├── _includes/
│ │ ├── components/
│ │ │ └── blog/
│ │ └── layouts/
│ └── blog/
│ └── posts/
└── .eleventy.js
src/— all source filessrc/_includes/layouts/— reusable page layoutssrc/_includes/components/— smaller reusable template piecessrc/_data/— global data available to every templatesrc/blog/posts/— your Markdown blog posts
Step 4: Create the 11ty Config
Create .eleventy.js in the project root:
module.exports = function (eleventyConfig) {
return {
dir: {
input: 'src',
output: 'dist',
includes: '_includes',
data: '_data'
},
templateFormats: ['njk', 'md', 'html'],
htmlTemplateEngine: 'njk',
markdownTemplateEngine: 'njk'
}
}
This tells 11ty where to find source files, where to write the output, and that we want to use Nunjucks (.njk) as our template engine — including inside Markdown files, which is what lets us use {{ variables }} in the front matter.
Part 2: Create the Base Layout
Every page on the site will extend a shared base layout — the HTML skeleton with <head>, meta tags, and a content block.
Step 5: Create base.njk
Create src/_includes/layouts/base.njk:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title }}</title>
<meta name="description" content="{{ description }}" />
</head>
<body>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
This is intentionally bare — no styles, no header, no footer. We'll build on it in Part 7 with SEO meta tags and structured data. The important part is the {% block content %} placeholder, which is what child layouts and pages will fill in.
Part 3: Set Up the Blog Collection
This is where 11ty starts to shine. A "collection" is just a group of pages sharing a tag. 11ty builds the collection automatically — all you do is tell it how to filter and sort.
Step 6: Add the Blog Collection
Update .eleventy.js:
module.exports = function (eleventyConfig) {
// Collect all posts tagged "blog", newest first
eleventyConfig.addCollection("blog", function(collectionApi) {
return collectionApi.getFilteredByTag("blog").sort((a, b) => {
return b.date - a.date;
});
});
return {
dir: {
input: 'src',
output: 'dist',
includes: '_includes',
data: '_data'
},
templateFormats: ['njk', 'md', 'html'],
htmlTemplateEngine: 'njk',
markdownTemplateEngine: 'njk'
}
}
Any Markdown file with tags: ["blog"] in its front matter now shows up in collections.blog, sorted newest first. You'll use this in the listing page in Part 5.
Step 7: Add Date Filters
11ty's default date output isn't great for templates — you'll want both a machine-readable ISO format (for <time datetime="..."> attributes and meta tags) and a human-readable format (for display). Add two custom filters:
eleventyConfig.addFilter("dateIso", (dateObj) => {
return new Date(dateObj).toISOString().split('T')[0];
});
eleventyConfig.addFilter("dateReadable", (dateObj) => {
return new Date(dateObj).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
Now {{ date | dateIso }} outputs 2026-04-18 and {{ date | dateReadable }} outputs April 18, 2026.
Part 4: Create the Blog Post Layout
Step 8: Create blog-post.njk
Create src/_includes/layouts/blog-post.njk. This extends base.njk and adds the post-specific structure:
{% extends "layouts/base.njk" %}
{% block content %}
<article>
<a href="/blog/">&larr; Back to blog</a>
<header>
<h1>{{ title }}</h1>
<time datetime="{{ date | dateIso }}">
{{ date | dateReadable }}
</time>
{% if description %}
<p>{{ description }}</p>
{% endif %}
</header>
<div class="content">
{{ content | safe }}
</div>
<a href="/blog/">&larr; Back to blog</a>
</article>
{% endblock %}
A few things worth pointing out:
{{ content | safe }}is where the Markdown body gets rendered. Thesafefilter tells Nunjucks not to escape the HTML 11ty generated from Markdown.<time datetime="{{ date | dateIso }}">is important for accessibility and SEO — search engines and assistive tech rely on the machine-readable date attribute.- The back link is repeated top and bottom. Readers should never have to scroll for navigation.
Part 5: Create the Listing Page and Post Card
Step 9: Create the Post Card Component
Create src/_includes/components/blog/post-card.njk. This is what renders each post on the listing page:
<article>
<time datetime="{{ post.date | dateIso }}">
{{ post.date | dateReadable }}
</time>
<h3>{{ post.data.title }}</h3>
<p>{{ post.data.description }}</p>
<a href="{{ post.url }}">
{{ post.data.title }} &rarr;
</a>
</article>
Note the shape: when you loop over a collection, each item exposes post.date, post.url, and post.data (everything from the post's front matter).
Step 10: Create the Blog Listing Page
Create src/blog/index.njk. This file will render at /blog/:
---
title: "Blog"
description: "Articles on web development and software engineering"
---
{% extends "layouts/base.njk" %}
{% block content %}
<h1>From the blog</h1>
{% for post in collections.blog %}
{% include "components/blog/post-card.njk" %}
{% endfor %}
{% endblock %}
Why src/blog/index.njk? 11ty maps file paths to URLs. An index file inside src/blog/ renders to /blog/, which is exactly what we want for the listing page.
The {% for post in collections.blog %} loop iterates over every post tagged "blog", sorted newest first, and passes each one to the card component.
Part 6: Write Your First Post
Step 11: Create a Markdown Post
Create src/blog/posts/my-first-post.md:
---
title: "My First Post"
description: "A short summary that appears on the blog listing and in search results."
date: 2026-04-18
tags: ["blog"]
layout: layouts/blog-post.njk
permalink: "/blog/{{ title | slugify }}/"
---
## Hello, World
This is the body of the post. You can write **Markdown** here —
headings, lists, code blocks, links, images, whatever you need.
### Code blocks work too
\`\`\`javascript
const greeting = "Hello from 11ty";
console.log(greeting);
\`\`\`
Step 12: Understand the Front Matter
Here's what each field does:
| Field | Required | Description |
|---|---|---|
title | Yes | Post title. Displayed as H1 and used for URL generation. |
description | Yes | 1–2 sentence summary shown on the listing page and used for meta tags. |
date | Yes | Publication date in YYYY-MM-DD format. Used for sorting. |
tags | Yes | Must include "blog" or the post won't show up in the collection. |
layout | Yes | Points to the layout file — layouts/blog-post.njk. |
permalink | Yes | URL pattern. Uses 11ty's built-in slugify filter to turn the title into a URL-safe slug. |
Important: Don't wrap the date value in quotes — 11ty parses unquoted YAML dates as Date objects. Quoted dates get treated as strings, which breaks sorting.
The permalink: "/blog/{{ title | slugify }}/" pattern means the title "My First Post" generates the URL /blog/my-first-post/. You get clean, SEO-friendly URLs for free, without having to maintain them by hand.
Step 13: Preview It
Run the dev server:
npm run serve
Visit http://localhost:8080/blog/ and you should see your new post in the listing. Click through and you'll land on /blog/my-first-post/ with the full post rendered.
Note: I write all of my blog content in Markdown using Claude Code. I describe the post I want, point it at the existing posts as a voice reference, and iterate on the draft directly in the editor. It keeps the authoring loop tight — no CMS, no context-switching, and the output is just a Markdown file committed to git alongside the rest of the source.
Part 7: SEO and Structured Data
A blog post without proper meta tags and structured data is invisible to social platforms and harder for search engines to index correctly. This section upgrades blog-post.njk into something search- and share-ready.
Step 14: Add Open Graph Meta Tags
Open Graph tags control how the post looks when shared on Facebook, LinkedIn, Slack, Discord, and anywhere else that scrapes the <head> for preview data. Add these to the <head> of base.njk (or directly in a {% block head %} overridden by blog-post.njk):
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ title }}" />
<meta property="og:description" content="{{ description }}" />
<meta property="og:image" content="{{ client.domain }}{{ image if image else '/assets/images/og-image.webp' }}" />
<meta property="og:url" content="{{ client.domain }}{{ page.url }}" />
<meta property="article:published_time" content="{{ date | dateIso }}" />
<meta property="article:modified_time" content="{{ (updated if updated else date) | dateIso }}" />
The updated field is optional in the front matter — if you add it to a post, the article:modified_time tag reflects the edit date, which is a small but real SEO signal.
Step 15: Add Twitter Card Meta Tags
Twitter (and X) use a separate set of meta tags:
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ title }}" />
<meta name="twitter:description" content="{{ description }}" />
<meta name="twitter:image" content="{{ client.domain }}{{ image if image else '/assets/images/og-image.webp' }}" />
summary_large_image gives you the big image card on Twitter rather than the thumbnail-style small card.
Step 16: Add a Canonical Link
Search engines use the canonical URL to deduplicate pages that might be accessible at multiple URLs. Add this inside <head>:
<link rel="canonical" href="{{ client.domain }}{{ page.url }}" />
Step 17: Add JSON-LD Article Schema
JSON-LD is structured data that tells Google exactly what a page is about. For blog posts, the Article schema type is what you want. Add this inside <head>:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "{{ title }}",
"description": "{{ description }}",
"image": "{{ client.domain }}{{ image if image else '/assets/images/og-image.webp' }}",
"datePublished": "{{ date | dateIso }}",
"dateModified": "{{ (updated if updated else date) | dateIso }}",
"author": {
"@type": "Person",
"name": "{{ client.name }}",
"url": "{{ client.domain }}/about/"
},
"publisher": {
"@type": "Organization",
"name": "{{ client.name }}",
"logo": {
"@type": "ImageObject",
"url": "{{ client.domain }}/assets/images/logo.png"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ client.domain }}{{ page.url }}"
}
}
</script>
This is what unlocks rich results in Google — article publication dates, author attribution, and eligibility for the "Top Stories" carousel for timely content.
Step 18: Add BreadcrumbList Schema
Breadcrumb structured data tells Google where a page sits in your site hierarchy, and can make breadcrumb trails appear in search results instead of the raw URL:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "{{ client.domain }}/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Blog",
"item": "{{ client.domain }}/blog/"
},
{
"@type": "ListItem",
"position": 3,
"name": "{{ title }}",
"item": "{{ client.domain }}{{ page.url }}"
}
]
}
</script>
Step 19: Install the Sitemap Plugin
Sitemaps help search engines discover and crawl every page on your site. 11ty has an official community plugin:
npm install @quasibit/eleventy-plugin-sitemap
Register it in .eleventy.js:
const sitemap = require('@quasibit/eleventy-plugin-sitemap')
module.exports = function (eleventyConfig) {
eleventyConfig.addPlugin(sitemap, {
sitemap: {
hostname: 'https://yourdomain.com'
}
})
// ... existing collection and filters ...
return { /* ... */ }
}
Create src/sitemap.xml with this minimal template:
---
permalink: /sitemap.xml
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{%- for page in collections.all %}
<url>
<loc>https://yourdomain.com{{ page.url }}</loc>
</url>
{%- endfor %}
</urlset>
After npm run build, you'll find a fully populated sitemap.xml in dist/. Submit it to Google Search Console and you're done.
Part 8: Global Data
The SEO snippets above reference {{ client.name }} and {{ client.domain }}. That data comes from the _data/ folder, where every file is automatically exposed to every template.
Step 20: Create Global Data Files
Create src/_data/client.js:
module.exports = {
name: "Your Name",
domain: "https://yourdomain.com"
};
Create src/_data/global.js:
module.exports = {
currentYear: new Date().getFullYear()
};
Now {{ client.name }}, {{ client.domain }}, and {{ global.currentYear }} are available in every template — handy for footer copyright years, meta tags, and author attribution without repeating yourself.
Part 9: Quality-of-Life Plugins
These aren't required, but I install them on every 11ty site.
Minify HTML, CSS, and JS Output
npm install @sherby/eleventy-plugin-files-minifier
const eleventyPluginFilesMinifier = require('@sherby/eleventy-plugin-files-minifier')
eleventyConfig.addPlugin(eleventyPluginFilesMinifier)
This automatically minifies your build output with zero configuration.
Navigation Helpers
If you want to drive your nav menu from data rather than hard-coding links:
npm install @11ty/eleventy-navigation
const eleventyNavigationPlugin = require('@11ty/eleventy-navigation')
eleventyConfig.addPlugin(eleventyNavigationPlugin)
You can then add eleventyNavigation: { key: "Home", order: 1 } to a page's front matter and generate a menu from it in any template.
Part 10: Build and Deploy
Local Development
npm run serve
Opens a live-reloading dev server at http://localhost:8080. Edit any Markdown or template file and the browser refreshes automatically.
Production Build
npm run build
Produces a fully static dist/ directory — just HTML, CSS, JS, and assets. No runtime dependencies.
Hosting
Because the output is just a directory of static files, you can host it literally anywhere — Netlify, Cloudflare Pages, Vercel, GitHub Pages, S3, or a plain old VPS. There's no runtime, no server code, no database. Just HTML, CSS, JS, and assets.
I host this site on a Linode VPS running nginx. Nginx is pointed at /var/www/alexanderkey.com/_site, and my entire deploy pipeline is a three-line bash script:
#!/bin/bash
echo "Building site..."
npm run build
echo "Deploying to server..."
rsync -avz --delete dist/ user@your-server-ip:/var/www/yourdomain.com/_site
echo "Deployment complete!"
That's it. npm run build produces a fresh dist/, and rsync syncs it to the server — the --delete flag cleans up any files that no longer exist in the build. One command (./deploy.sh) and the site is live.
If you go the VPS route, I'd strongly recommend hardening the server before you point nginx at anything.
Checklist
Before you ship, make sure you've got:
- Base layout at
src/_includes/layouts/base.njk - Blog post layout at
src/_includes/layouts/blog-post.njk - Post card component at
src/_includes/components/blog/post-card.njk - Listing page at
src/blog/index.njk - Blog collection registered in
.eleventy.js dateIsoanddateReadablefilters registered in.eleventy.js- At least one post in
src/blog/posts/with the required front matter - Open Graph, Twitter Card, and canonical meta tags in the base layout
- JSON-LD
ArticleandBreadcrumbListschema on post pages - Sitemap plugin registered and
sitemap.xmlgenerated - Global data in
src/_data/client.js
That's the whole setup. The blog you're reading right now uses this exact stack, and the full working version is at github.com/AlexKeyCodes/portfolio — clone it if you want a head start.