The MDX reader.
Point it at a folder. Get a site. Vertō — to turn the page.
Verto is to MDX what Obsidian is to Markdown — a reader that treats a folder of files as a first-class library.
Drop any collection of .mdx (or .md) files into content/ and Verto
turns the folder into a navigable, statically-rendered site: file-tree
sidebar, table of contents, breadcrumbs, prev/next, and a rich set of
MDX block components — all pre-rendered at build time.
Verto is a reader, not a CMS and not an editor. There is no database, no admin UI, no required frontmatter. Your files are the source of truth; the file system is the schema. If you can write MDX in any editor — VS Code, Obsidian, Cursor, vim — Verto can read it.
Markdown is a great format for plain text. MDX is what you reach for the moment your notes want to do something — embed a callout, lay out a comparison table, sketch a diagram, attach a comment, drop in an interactive component. Verto is built around that need:
- MDX is native. Components are first-class;
.mdis treated as a strict subset that just works. - A built-in component library. Callouts, Toggles, Bookmarks, Figures, Task Lists, code blocks with line highlighting, inline-comment popovers — ready out of the box, no imports required.
- Unknown components don't crash. Third-party MDX with custom JSX renders a friendly placeholder instead of throwing — paste from anywhere.
- Static-first. Every page is pre-rendered. Zero runtime, deploy anywhere.
| Obsidian (Markdown) | Verto (MDX) | |
|---|---|---|
| Source of truth | A folder (vault) of .md |
A folder (content/) of .mdx / .md |
| Schema | None — files and folders | None — files and folders |
| Extensibility | Plugins | MDX components |
| Reading UI | Built-in reader pane | Statically-rendered Next.js site |
| Lock-in | None — plain text on disk | None — plain text on disk |
| Output | Local app | A site you can host anywhere |
- 🧩 10+ built-in block components — Callout, Toggle, BookmarkCard, Figure, TaskList, Table, BlockquoteStyled, CodeBlock, and more — no imports required
- 🎨 Shiki syntax highlighting — dual light/dark themes, rendered at build time, zero client JS
- 💬 Inline comments —
[^c-N]footnotes become highlighted text with click-to-reveal popovers → demo - 🛡️ Unknown-component fallback — MDX from anywhere won't crash; unmapped JSX tags render as a friendly placeholder
- 📄
.mdworks too — same pipeline, same components, same output
- 📁 Auto file-tree sidebar — recursively scans
content/, collapsible directories, current-file highlight - 🪶 Optional frontmatter — title falls back to first H1 then filename; description to the first paragraph; sort by
order, date, then title - 🧭 Breadcrumbs + prev/next — derived from the file tree's reading order
- 🗂 Directory index pages — landing on a folder lists its contents (or renders
_index.mdif present) - 🎛 Surgical overrides — optional
content/navigation.jsonto rename, sort, or hide entries without renaming files
- 📊 Reading-progress bar — thin indicator below the navbar, updates on scroll
- 🌓 Dark mode — CSS variables, no-flash script, persists preference
- ⚡ Pre-rendered at build time — every page statically generated, ready for Vercel
- 📱 Responsive — mobile-first layout with adaptive breakpoints
- 📦 Node.js 18.17 or higher
git clone https://github.com/tsaiggo/verto.git
cd verto
npm install
npm run devSite runs at http://localhost:3000.
| Command | Description |
|---|---|
npm run dev |
Dev server with hot reload |
npm run build |
Static production build |
npm start |
Serve the production build |
npm run lint |
ESLint |
npm test |
Vitest suite |
npx vercelStatic generation by default. No config needed.
verto/
├── app/
│ ├── page.tsx → Reader home (sections + recently updated)
│ ├── read/[[...path]]/ → Unified document route
│ └── layout.tsx → Root layout (Navbar + Footer + theme script)
├── components/
│ ├── reader/ → FileTree, Breadcrumb, PrevNext, ReadingProgress, DirectoryIndex
│ ├── layout/ → Navbar, TableOfContents, Footer
│ ├── mdx/ → Block components + UnknownComponent fallback
│ └── ui/ → ThemeToggle, MobileMenu, selection-share helpers
├── content/ → Your vault — drop .mdx / .md here, any depth
│ └── navigation.json → Optional sort / hide / rename overrides
└── lib/
├── content-source/ → Pluggable storage backend (local, github, onedrive)
│ ├── types.ts → ContentSource / RawFileEntry / ContentNode types
│ ├── tree.ts → Source-agnostic tree builder + slug resolvers
│ ├── local.ts → Filesystem source (default)
│ ├── github.ts → GitHub repo source (Git Trees API)
│ ├── onedrive.ts → OneDrive source (Microsoft Graph)
│ └── index.ts → Source selector (VERTO_CONTENT_SOURCE)
├── content-source.ts → Re-export bridge (legacy import path)
├── mdx.ts → Compile + render pipeline (Shiki, GFM, inline-comments)
├── plugins/ → remark/rehype-inline-comments
├── shiki.ts → Lazy-loaded highlighter
├── toc.ts → Heading extraction for the right sidebar
└── format.ts → Date formatter
Drop a .mdx or .md file anywhere under content/. The URL mirrors the
file path:
| File | URL |
|---|---|
content/notes/quick-thought.md |
/read/notes/quick-thought |
content/blog/2026/launch.mdx |
/read/blog/2026/launch |
content/projects/_index.md |
/read/projects |
---
title: My Document
description: Shown in directory listings and meta tags.
date: "2026-05-14"
author: Me
tags: ["draft", "ideas"]
order: 1
hidden: false
---
Your content here.When a field is omitted Verto fills it in:
| Field | Fallback |
|---|---|
title |
First # H1 heading → humanized filename |
description |
First non-heading paragraph (truncated) |
date |
File modification time (shown as "Updated …") |
order |
Date → alphabetical |
A file named _index.md, index.md, or README.md inside a directory
becomes that directory's landing page. Without one, Verto renders an
auto-generated index listing the directory's children.
Use this file only when you want to override what the file system would do naturally:
{
"overrides": {
"docs": { "title": "Docs", "order": 1 },
"drafts": { "hidden": true },
"notes/old-name": { "title": "New Name" }
}
}Keys are slug paths relative to content/, without the file extension.
| Component | Description |
|---|---|
Callout |
Admonitions: info, warning, tip |
Toggle |
Collapsible content block |
BookmarkCard |
Link preview card with title + description |
Figure |
Image with caption |
DiagramPlaceholder |
Placeholder for diagrams |
TaskList |
Checkbox task lists |
Table |
Styled Markdown tables |
BlockquoteStyled |
Styled blockquotes |
CodeBlock |
Shiki-highlighted code with dual themes |
InlineCode |
Styled inline code spans |
UnknownComponent |
Placeholder shown when a doc references an unmapped JSX component |
The signature feature, repurposed for the reader: turn footnote-style annotations into floating popovers as you read.
This took real effort[^c-1] to get right.
[^c-1]: Three days of SSR debugging. Worth it.[^c-N]→ highlighted text + popover in Verto[^N]→ regular footnote (still works)- Degrades to standard footnotes on GitHub — no content lost either way
The previous routes — /docs/* and /blog/* — are now permanent (308)
redirects to /read/docs/* and /read/blog/*. Existing content under
content/docs/ and content/blog/ continues to work unchanged.
Verto resolves the readable content behind /read/* through a pluggable
ContentSource abstraction. By default it walks the local ./content
directory, but the same site can be pointed at a remote vault — a GitHub
repository or a OneDrive folder — by setting environment variables. See
.env.example for the full list.
| Source | When to use | Required env |
|---|---|---|
local (default) |
Files in the repo; static site, no network | none |
github |
Vault lives in a GitHub repo (public or private) | VERTO_GITHUB_REPO |
onedrive |
Vault lives in OneDrive (shared link or private) | VERTO_ONEDRIVE_SHARE_URL or VERTO_ONEDRIVE_REFRESH_TOKEN (+ client id/secret) |
Pick the source with VERTO_CONTENT_SOURCE (local | github | onedrive).
The selected source is used at build time, so changing content still
requires a rebuild — Verto remains a statically-rendered reader.
VERTO_CONTENT_SOURCE=github
VERTO_GITHUB_REPO=owner/repo
VERTO_GITHUB_BRANCH=main # optional, defaults to "main"
VERTO_GITHUB_PATH=content # optional sub-directory in the repo
VERTO_GITHUB_TOKEN=ghp_xxx # optional; required for private reposA single Git Trees API call enumerates the whole repo, then individual
files are fetched as blobs on demand. Without a token the unauthenticated
rate limit is 60 requests/hour — set VERTO_GITHUB_TOKEN (a fine-grained
PAT with Contents: read is enough) to raise it to 5000/h.
Two operating modes — share-URL mode is the simplest:
VERTO_CONTENT_SOURCE=onedrive
VERTO_ONEDRIVE_SHARE_URL=https://1drv.ms/u/s!...
VERTO_ONEDRIVE_PATH=content # optional sub-folder inside the shared itemAny user with the share link can read the folder, so no OAuth is needed.
Verto encodes the share URL into Microsoft Graph's u!… share-id scheme
and walks the folder via /shares/{id}/driveItem.
For private content register a Microsoft Entra (Azure AD) app, grant
it Files.Read + offline_access, complete a one-off auth dance to get
a refresh token, and configure:
VERTO_CONTENT_SOURCE=onedrive
VERTO_ONEDRIVE_TENANT=common # or "consumers" / a tenant GUID
VERTO_ONEDRIVE_CLIENT_ID=...
VERTO_ONEDRIVE_CLIENT_SECRET=...
VERTO_ONEDRIVE_REFRESH_TOKEN=...
VERTO_ONEDRIVE_PATH=contentTokens are refreshed automatically each build. The implementation
respects Graph @odata.nextLink pagination and backs off on 429 /
Retry-After.
- Remote sources don't reliably surface a per-file modification time.
Prefer frontmatter
date/updated/orderfor deterministic sort. navigation.jsonlives at the source root — for GitHub that'sVERTO_GITHUB_PATH/navigation.json, for OneDrive it'sVERTO_ONEDRIVE_PATH/navigation.json.
This project is licensed under the Apache License 2.0. See the LICENSE file for details.
The same codebase can ship as a native desktop app on macOS, Windows and Linux via Tauri 2. The web build is unchanged — desktop is opt-in.
src-tauri/holds the Rust shell andtauri.conf.json.- For desktop builds the Next.js app is statically exported
(
output: 'export', gated onTAURI=1), and Tauri loads theout/folder directly from disk — no Node server at runtime. - A small Check for updates button appears in the navbar only
when running inside Tauri (detected via
window.__TAURI_INTERNALS__), so the browser build is unaffected.
npm install # one time
npm run tauri:dev # spawns `next dev` and opens the Tauri windownpm run tauri:build # → src-tauri/target/release/bundle/...Before the first build you need icons; generate them once from any square PNG / SVG (the included app icon works):
npx @tauri-apps/cli icon app/icon.svgInstallers are hosted on GitHub Releases and the in-app updater fetches its manifest from a stable URL:
https://github.com/tsaiggo/verto/releases/latest/download/latest.json
.github/workflows/release.yml runs on every pushed v* tag, builds
on a macOS / Windows / Linux matrix using
tauri-apps/tauri-action,
signs the artifacts, uploads them to a draft Release, and
auto-generates latest.json. Cut a release with:
git tag v0.2.0
git push origin v0.2.0
# then review and publish the draft release on GitHubThe updater verifies every downloaded package against an embedded public key. Generate the key pair once:
npx @tauri-apps/cli signer generate -w ~/.tauri/verto.keyThen:
| Where | What |
|---|---|
src-tauri/tauri.conf.json → plugins.updater.pubkey |
The public key printed by the command |
GitHub repo secret TAURI_SIGNING_PRIVATE_KEY |
Contents of ~/.tauri/verto.key |
GitHub repo secret TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
The password you chose |
Back up the private key somewhere safe — if it's lost you cannot ship updates that existing installs will accept.
Made with ❤️ by tsaiggo