← Blog

How this blog works (and where it bit me)

A tour of the Astro + Trilium ETAPI pipeline behind this blog, and the sharp edges I hit building it.

|3 min read|
astrotriliumsveltedaisyui

This blog has no posts/ folder. Every article you’re reading — including this one — lives as a note in a self-hosted Trilium instance, and gets pulled into the site at build time. Here’s how that pipeline fits together, and the handful of things that drew blood on the way.

The shape of it

The site is Astro with Svelte islands, styled with Tailwind v4 + DaisyUI. Posts are an Astro content collection, but instead of the built-in glob() loader reading Markdown files, there’s a custom loader that talks to Trilium’s ETAPI:

src/content.config.ts
const blog = defineCollection({
loader: triliumLoader({
url: env.TRILIUM_URL,
token: env.TRILIUM_ETAPI_TOKEN,
blogRootId: env.TRILIUM_BLOG_ROOT_ID,
}),
schema: z.object({
title: z.string(),
date: z.string(),
description: z.string(),
tags: z.array(z.string()).optional(),
draft: z.boolean().optional().default(false),
}),
});

Writing a post means creating a note, tagging it #publish, and rebuilding. The loader finds it, maps the note’s labels onto that Zod schema, renders the body as Markdown, and every [slug].astro page falls out of getStaticPaths().

The loader, roughly

// src/loaders/trilium.ts (trimmed)
async load({ store, logger, parseData, renderMarkdown, generateDigest }) {
const { results } = await request(`/notes?search=%23publish&fastSearch=true`);
store.clear();
for (const summary of results) {
const note = await request(`/notes/${summary.noteId}`);
const body = await request(`/notes/${summary.noteId}/content`, /* asText */ true);
const { slug, data } = mapLabels(note);
store.set({
id: slug,
data: await parseData({ id: slug, data }),
body,
rendered: await renderMarkdown(body),
digest: generateDigest(body),
});
}
}

That’s the whole idea. The rest of this post is the sharp edges.

Pitfall 1 — .env isn’t there when you need it

The loader is wired up from content.config.ts, and Astro evaluates that file before it populates process.env with your .env. So process.env.TRILIUM_URL is undefined exactly when the loader is being constructed. The fix is to read the env explicitly with Vite’s loadEnv:

// prefix "" also merges real environment variables, not just .env files
const env = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), '');

Pitfall 2 — ETAPI wants the bare token

The auth header is not Bearer TOKEN. Trilium’s ETAPI expects the token verbatim:

const headers = { Authorization: token }; // no "Bearer " prefix

Add the prefix out of habit and every request comes back 401.

Pitfall 3 — search results are half-empty, and /content isn’t JSON

A #publish search gives you note summaries — no attributes, no dates. To get the labels and body you fetch each note twice more. And the content endpoint returns raw text, so calling .json() on it throws:

const note = await request(`/notes/${id}`); // full metadata
const body = await request(`/notes/${id}/content`, true); // asText -> res.text()

Pitfall 4 — labels are untyped strings

Trilium labels are all strings, so the schema has to be reconstructed by hand — split the tags, coerce the boolean, slice the date out of an ISO timestamp:

const date = labels.publishDate || (note.dateCreated ?? '').slice(0, 10);
const tags = labels.tags ? labels.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const draft = labels.draft === 'true';

Also worth knowing: the loader assumes the body is Markdown. Author the note as a code note with mime text/x-markdown. A normal rich-text note is HTML and would render as escaped literal text, not a formatted post.

Pitfall 5 — don’t let a bad build wipe the blog

If the Trilium server is unreachable at build time, the naive move is to throw and fail the build — or clear the store and ship an empty blog. Neither is good. The loader only calls store.clear() after a successful search, returns early (keeping last-good entries) if the search fails, and catches per-note errors so one broken note doesn’t take down the rest:

try {
const { results } = await request(`/notes?search=%23publish...`);
notes = results ?? [];
} catch (err) {
logger.error(`Trilium search failed, keeping existing entries: ${err.message}`);
return; // note: BEFORE store.clear()
}
store.clear();

Pitfall 6 — DaisyUI v5 dropped the --p shorthands

There’s no @tailwindcss/typography here — post styling is a hand-rolled .content-prose block. When theming it, the old oklch(var(--p)) trick is a trap: DaisyUI v5 removed the --p / --s / --a shorthand vars, and wrapping an empty value in oklch() produces invalid CSS that silently falls back to defaults. Use the full token, and do opacity with color-mix:

.content-prose code {
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}

Pitfall 7 — view transitions eat your theme

Astro’s ClientRouter swaps the entire document root (the html element) on navigation, and the theme lives as data-theme on that element — so it vanishes mid-navigation and the page flashes to default. The fix is to copy it onto the incoming document before the swap:

document.addEventListener('astro:before-swap', (e) => {
const from = document.documentElement;
const to = e.newDocument.documentElement;
to.setAttribute('data-theme', from.getAttribute('data-theme') ?? 'dark');
});

Worth it?

Absolutely. Writing happens in the same tool I already keep notes in, publishing is one label, and the site stays a fast static build. The cost is a loader with opinions — but every opinion above is one bug I only had to fix once.