I write long technical deep dives in Obsidian, mostly about how React and Next.js work under the hood. Some of them run past 5,000 words. I wanted them on the web without giving up what makes the vault useful (wikilinks, callouts, embeds, Mermaid, math) and without keeping a second copy of every note in a CMS.
Obsidian Publish and Quartz both exist and both are good. I wanted full control over the reading experience, and I wanted an excuse to build a real app on TanStack Start. The result is Obsidian Garden, MIT-licensed:
This post walks through the parts that were interesting to build.
1. The vault never touches the build
The first decision was to keep the vault out of the deploy entirely. A one-way sync script (pnpm sync, or pnpm sync:watch while writing) mirrors publishable notes into content/ and their attachments into public/attachments/. It never writes to the vault. The site builds only from those committed files.
That gives me three things:
- A deploy is a normal
git push. Nothing on the server needs access to my notes. - Every published change shows up as a diff in git before it goes live.
- The build is reproducible on any machine.
The sync is also where the publishing rules live. Each note ends up in one of three states:
-
Excluded:
publish: falseordraft: truein frontmatter, or an empty.nopublishfile in the folder. The note never leaves the vault, and if an earlier sync copied it, the copy is removed. -
Private:
visibility: private,private: trueor a#privatetag, or a.privatemarker file. The note is synced, but the site hides it from visitors. "Private" here means "not polished yet", not "secret". The files are in the repo. - Public: everything else.
Folder rules cascade down the tree and can't be loosened further down. A visibility: public note inside a private folder stays private, and the sync prints a warning about it. Attachments are resolved per note, so an image used only by private notes is never published.
2. Obsidian syntax as a remark plugin
Content is compiled with @mdx-js/rollup at build time. Most of the work is one remark plugin, remark-obsidian, that turns Obsidian-flavoured markdown into nodes the UI can style:
-
[[Note|text]]becomes a link to/posts/<slug>, or to the heading anchor for[[Note#Heading]]. -
![[image.png|alt|300x200]]becomes an<img>with width and height. Video, audio and PDF embeds get their own elements. -
> [!warning]- Titlebecomes a foldable callout. The-/+suffix maps to closed/open. -
==highlight==,%%comments%%, inline#tagsand^block-idsare handled too.
Wikilinks resolve against a catalog built once per compile: every note's path, slug, title and aliases. Lookups are case-insensitive and accept Note, folder/Note, Note.md or an alias, the same way Obsidian resolves them.
One detail I'm happy with: a link to a note the reader can't see doesn't produce a broken link. It renders as plain text with a screen-reader-only "(not published)". So a public note that links to a private draft doesn't leak the draft's URL.
The plugin also records every resolved outgoing link per note. Those links feed the backlinks list and the graph later.
Shiki highlighting and KaTeX run at build time. Mermaid is the exception. It renders on the client when a diagram scrolls into view, and its theme variables come from the site's CSS design tokens. When you toggle dark mode, the diagrams are re-themed to match the page.
3. No runtime filesystem
Every note is bundled through import.meta.glob. Metadata and search text are eager imports. Note bodies are lazy chunks:
const loaders = import.meta.glob<MDXContent>(
["/content/**/*.{md,mdx}", "!/content/_private/**"],
{ import: "default" },
);
Because nothing reads the disk at runtime, the same server bundle runs on Node, Bun, Vercel, Netlify or Cloudflare. Nitro picks the target from NITRO_PRESET at build time, and the app code doesn't change.
Notice the !/content/_private/** exclusion. Private notes are never emitted as client chunks, so nobody can fetch one by guessing a chunk URL. They're compiled to function-body code on the server and only leave it through one server function.
4. Typed server functions as the only data door
The UI never imports content directly. Every read goes through a TanStack Start server function with a zod validator, and every handler resolves the viewer first:
export const getPost = createServerFn({ method: "GET" })
.validator(z.object({ slug: slugSchema }))
.handler(({ data }) => {
const detail = indexFor(getViewer()).detail(data.slug);
if (!detail) throw notFound();
return detail;
});
indexFor(viewer) returns one of two in-memory indexes. Anonymous visitors get an index built only from public notes, so private notes don't exist for them at all. That covers lists, search, tags, the graph, backlinks, related posts, RSS and the sitemap with no per-feature filtering. The full index is reserved for an admin viewer. Today that only means local development with SHOW_PRIVATE=1, but a real login would only need to replace getViewer().
5. Picking an SSR mode per route
TanStack Start lets each route choose how much to render on the server, and I used all three options:
-
Notes (
ssr: true): the loader fetches the note, its folder and the MDX chunk in parallel, so the article arrives fully rendered. Related posts are returned as an unawaited promise and streamed in with<Await>below the article, so they never hold up first paint. -
Search (
ssr: "data-only"): the loader runs on the server on first load, so results arrive with the HTML, but the interactive UI renders on the client. -
Graph (
ssr: false): the graph is a d3-force simulation on a HiDPI<canvas>, sized from the real viewport. Server-rendering it would only produce an empty box.
6. Backlinks and the graph
The index builds an outgoing and incoming link map from the links that remark-obsidian recorded. Backlinks are a lookup in the incoming map. The graph is the deduplicated edge list plus a degree count per node, which sizes the nodes. On the client, forceLink, forceManyBody and a few other forces lay it out, and you can filter the graph by tag.
7. Courses from folders
A lot of my writing is serial. A folder becomes a course at /series/<folder> when it has a main note (<dir>/<dir>.md, <dir>.md next to it, or index.md). That note's frontmatter supplies the title, description and an SVG cover. Numbered filenames like 07 Fiber.md set the order, and the number prefix is stripped from the displayed title. Covers are inlined as SVG so they follow the theme.
8. SEO configured from the vault
I didn't want SEO settings in code. A note with site: settings in its frontmatter holds the site title, URL and defaults, and site: author notes describe authors. The sync collects them into content/site.json. From there the app generates the sitemap, robots.txt, RSS, canonical URLs, social cards and JSON-LD for posts, authors, courses and breadcrumbs. These are all TanStack Start server routes. Individual notes can override noindex, canonical, social-image and schema.
What I'd do differently
- Mermaid on the client is the heaviest thing on the page. I'd like to pre-render diagrams to SVG at build time and keep the client path only for theme switching.
- "Private" notes are visible in the repo. That's documented, but a separate private content repo would be cleaner for people who want true secrecy.
- There's no Dockerfile yet, which would make self-hosting a one-liner.
Try it on your vault
The repo includes a fork-and-go guide (docs/publishing-your-vault.md) and a vault starter kit. Fork it, point OBSIDIAN_VAULT at your vault, run pnpm sync && pnpm dev, and deploy to Vercel or anywhere Nitro runs.
I'd love feedback, especially on which Obsidian syntax you'd expect a publisher like this to support that's still missing.
This article was written with the help of AI, working from the project's source code. I checked it against the code before publishing.


Top comments (0)