Daniel Schulz
Daniel Schulz iamschulz avatar

Mostly frontend, sometimes art

my Mastodon
An Opossum sitting on top of a cardboard box with a Notion logo printed on it. Hand-drawn illustration style.

From Notion to Eleventy

Static Site Generators are a great tool to generate a JAM Stack website from a data feed. That feed can be markdown files, or like in the case of Eleventy, an API endpoint that returns its data as an object. I thought It would be simple enough to attach Notion, as it makes an awesome CMS. The two seem like they could pair up really well, but getting them actually to play nice with each other takes some work.

Notion Structure

I'm a beginner in Notion. I use it at work to write some documentation, but that's about the extent of what I know about it.

I first tried to add each article as a separate page, but soon realized I have no way of managing properties of blog posts, or as it's called in static site generators - front matter. The way to go is databases in Notion. They can be set up to have as many properties as you want. Each entry is also automatically a page.

A screenshot of a notion database titled “Posts”. The table columns are: Title (string), Draft (boolean), Date (date) and Cover Alt (string). The database has six dummy entries with blind text titles. The fifth entry is marked as a draft.

The Notion API gives me an endpoint to fetch the database and a further endpoint for each post. Eleventy has its _data structure, in which it can fetch data from the API and use it to populate a site with content. So my first try was to simply fetch pages from the Notion database and go on with that. If pure text is all you want, it's pretty straightforward. But Notion has a lot more to offer than that. There's formatting, images, embeds, code blocks... And most of them come with a little hurdle.


Notion works with content blocks. Each paragraph and each formatted section is a block on its own. Fetching that from Notion gets me a load of nested objects that are cumbersome to work with. I'm using NotionToMarkdown to take care of that. This will give me a markdown string from a Notion page that's easy to work with. Beware it’s just a markdown string, though. Using it in a template will output the unrendered string. But if we put that into a markdown file, from which eleventy will generate pages, it will also render the markdown string to HTML.

// data
const { Client } = require('@notionhq/client');
const { NotionToMarkdown } = require('notion-to-md');

module.exports = async () => {
const notion = new Client({ auth: process.env.NOTION_KEY });
const n2m = new NotionToMarkdown({ notionClient: notion });

const databaseId = process.env.NOTION_BLOG_ID;
const db = await notion.databases.query({
database_id: databaseId,
filter: {
property: 'Draft',
checkbox: { equals: false, },
sorts: [
property: 'Date',
direction: 'descending',

const getContent = async (id) => {
const mdblocks = await n2m.pageToMarkdown(id);
return n2m.toMarkdownString(mdblocks);

const posts = db.results.map((result) => ({
id: result.id,
title: result.properties['Title'].title.pop().plain_text,
content: undefined,
cover: result.cover?.file?.url || result.cover?.external?.url,
coverAlt: result.properties['Cover Alt']?.rich_text.pop()?.plain_text || '',
date: result.properties['Date']?.date.start,

for (i = 0; i < posts.length; i++) {
posts[i].content = await getContent(posts[i].id);

return posts;
// foo.md

layout: 'foo.njk'
data: foo
size: 1
alias: post

{{ post.content }}
// foo.njk
{% extends "base.njk" %}

{%block content %}
<p>This will not render markdown:</p>
{{ post.content }}
{% endblock %}

{% block content %}
<p>But this will:</p>
{{ content | safe }}
{% endblock %}


Let’s take a look at images. The ones I upload to Notion are stored in Notion’s cloud. But I want to serve them from the same origin as my static site for reasons like performance and security. Eleventy has a really good Image Plugin, but it won’t replace my Notion images out of the box. I need a custom shortcode for images that invokes the image plugin. The plugin documentation suggests two ways to go about this: an asynchronous and a synchronous function. The asynchronous one works out of the box, but can't be used in Nunjucks macros, because they don't like unresolved Promises when they render HTML. The synchronous one needs some tweaking.

It calls statsSync which does not support remote images and asks me to use statsByDimensionSync. That one needs manual input for the image width and height, which I don’t know either. But it only uses the image dimensions to write the width and height attributes on the <img> tag. I can manage without that and simply remove them again. The dimensions I give into the statsByDimensionsSync call are basically meaningless now.

const Image = require('@11ty/eleventy-img');

module.exports = function imageShortcode(src, alt) {
let options = {
widths: [300, 600],
formats: ['avif', 'webp', 'jpeg'],
outputDir: './dist/img/',

// generate images, while this is async we don’t wait
Image(src, options);

let imageAttributes = {
sizes: '(min-width: 30em) 50vw, 100vw',
loading: 'lazy',
decoding: 'async',

// get metadata even the images are not fully generated
const metadata = Image.statsByDimensionsSync(src, 600, 600, options);
let html = Image.generateHTML(metadata, imageAttributes);
html = html.replace(/(?:width|height)="[0-9]+"/gm, '');
return html;

I did have to work around a bug for this to work. Apparently, this method will not process all images correctly, leading to broken image links. Thankfully, this has already been reported and solved. The fix is inside the node package zeroby0/eleventy-img#issue-146. I expect this workaround to be obsolete soon.

Rendering, again

Now I have a custom shortcode in my Nunjucks parser, which can even process remote images, and the matching images in the markdown I get from Notion. But the custom shortcode won't trigger on ![alt text](image/location.jpg). because it's a custom shortcode, it needs to be invoked with {% image src="image/location.jpg", alt="alt text" %}. I’m replacing the markdown image tags for custom shortcuts in the API adapter in _data.

(markdown) => {
let result = markdown;

const regex = /!\[(?<alt>[^\]]+)?\]\((?<url>[^\)]+)\)/gm;
match = regex.exec(markdown);

while (match != null) {
const mdImage = match[0];
const alt = match[1];
const url = match[2];

if (!url) {
console.error(`url missing for ${mdImage}`);

if (!alt) {
console.error(`alt missing for ${mdImage}`);

// replace with new url
result = result.replace(mdImage, `{% image "${url}", "${alt}" %}`);

match = regex.exec(markdown);

return result;

But it’s still not doing its thing. Jed Fox on Eleventy’s Discord server helped me figure that out. Eleventy’s render pipeline looks a bit like this:

A box labeled “Markdown”, an arrow pointing to the next box labelled “markdown-it”, an arrow pointing to the next box labeled “Nunjucks Template”

What I’m doing now is this:

A box labeled “Markdown”, an arrow pointing to the next box labeled “markdown-it”, an arrow pointing to the next box labeled “Custom Shortcode”, an arrow pointing to the next box labeled “Nunjucks Template”

The custom shortcode is inserted after my markdown has been converted. I need to re-render it, luckily there’s a solution for that: the Render Plugin.

A box labeled “Markdown”, an arrow pointing to the next box labeled “markdown-it”, an arrow pointing to the next box labeled “Custom Shortcode”, an arrow pointing to the next box labeled “Render Plugin”, an arrow pointing to the next box labeled “Nunjucks Template”

So I installed it, but all I got back was an error inside Eleventy’s Template Renderer. A config key containing the project root had no value, but it all was outside of my scope.

[11ty] Unhandled rejection in promise: (more in DEBUG output)
[11ty] EleventyShortcodeError is not defined (via ReferenceError)
[11ty] Original error stack trace: ReferenceError: EleventyShortcodeError is not defined
[11ty] at /home/iamschulz/blorb/node_modules/@11ty/eleventy/src/Plugins/RenderPlugin.js:225:21
[11ty] at processTicksAndRejections (node:internal/process/task_queues:96:5)

According to the docs, this shouldn’t happen. Turns out I stumbled onto another known bug and the solution is to use the canary build.

yarn remove @11ty/eleventy
yarn add -D @11ty/eleventy@2.0.0-canary.16

Just like the Image Plugin, the Render Plugin needs its own custom shortcode as well. I went with the following:

async (content) => await eleventyConfig.javascriptFunctions.renderTemplate(content, 'njk')

Use this at your own discretion. eleventyConfig.javascriptFunctions is not documented and might only be working by accident. It might not be there in the next major release. Again, thanks to Jed Fox for guiding me through this.

And finally I got it working. Text, formatting and images from Notion to Eleventy. And even a blueprint on how to integrate other embeds as well, following the image example.


The one thing I really miss in Notion is webhooks, so I can auto-deploy my site on page changes. Or any ability to send POST requests on demand. Netlify has a build hook, and it only accepts POST requests, so I need some adaptation.

My workaround is a Zapier integration. Zapier works natively with Notion, but it only provides a trigger on new database items. It won't fire on database entry changes. That would deploy my blog once I add a new post, even as a draft. But once I set the draft property to false, it wouldn't update and the post won't be published.

I work around that with a generic GET endpoint in Zapier. It has a key attached as a query parameter, which Zapier will check. It then sends a POST request to Netlify’s build hook. The GET endpoint works as a normal link in my Notion blog page.

A Zapier integration. 1: Post in Webhooks by Zapier when catch hook. 2: Only continue if… 3: POST in Webhooks by Zapier

That won’t provide me will full automation, but at least I’ll have a one-click solution to export a blog from Notion to a live website.

A notion screenshot containing links to the “Blog and “Sites” subpages, a button labeled “Deploy” and a netlify build status badge, saying “Success”