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:
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 filesconst 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 " prefixAdd 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 metadataconst 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.