Markdown notes, backlinks, and a graph view in your browser.
Obsidian is wonderful and I do not own my notes inside it. Notion is hosted and I
cannot grep my notes. I wanted the Obsidian editing experience — [[wikilinks]],
backlinks panel, graph view — in a thing I can self-host and share a folder of
with collaborators in one click.
Inkwell is a small wiki / personal-knowledge-graph engine. Markdown is the source of truth, but the wiki view is collaborative, searchable, and indexed.
- Edit Markdown in the browser with a live side-by-side preview;
[[wikilinks]], GFM tables, and code blocks. - Backlinks panel and a force-directed graph view of the link structure.
- Multi-user workspaces with roles (owner / editor / reader) enforced by Postgres row-level security. New users create their first workspace in the app — no manual SQL.
- Passwordless sign-in: a magic link, with a 6-digit code fallback for when a mail scanner eats the link.
- "Who's here" presence on every page, and a sidebar page list that updates in realtime as collaborators save.
- Full-text search via Postgres
tsvector; recently-edited pages rank higher. - Page history: every edit is recorded as an append-only revision.
- Light / dark theme (follows your OS, with a manual toggle) and a mobile-friendly layout.
Not yet: live co-editing of the same page body (two people typing into one page will overwrite each other on save — a CRDT is on the roadmap), a workspace switcher UI (the app opens your oldest workspace), and the Git mirror worker.
React + Vite editor ──► Supabase (auth + realtime presence)
│
▼
Postgres
├── pages (markdown, frontmatter, tsvector)
├── links (page_id, target_slug) ← recursive CTE for the graph view
├── revisions (append-only history)
└── RLS (workspace roles are the security boundary)
There is no application server: the browser talks to Supabase directly and row-level security is the security boundary. See docs/ARCHITECTURE.md for the longer tour.
react · vite · typescript · react-markdown · d3-force ·
supabase (postgres, auth, realtime) · vitest
Requires Node ≥ 20 and the Supabase CLI (plus Docker) for the local database.
npm ci # install into ./node_modules
cp .env.example .env.local # fill in Supabase URL + anon key
npx supabase init # first time only; keeps existing migrations
npx supabase start # local Postgres + auth, prints URL and keys
npx supabase db reset # runs migrations 0001→0008 + seed
npm run dev # http://localhost:5173The seed creates a demo workspace with two pages. After your first
magic-link sign-in, add yourself to it from the SQL editor:
insert into workspace_members (workspace_id, user_id, role)
values ('00000000-0000-0000-0000-000000000001', auth.uid(), 'owner');Or skip the seed entirely and just sign in — the app's onboarding screen creates a workspace for you, the same flow production uses.
No Docker? Create a free hosted Supabase project instead and follow steps
1–2 of the Vercel guide below, pointing .env.local at it.
npm test # vitest, single run
npm run test:watch # watch mode
npm run typecheck # tsc --noEmit
npm run lint # eslint, zero warnings allowed
npm run test:db # apply migrations to a real Postgres and assert RLSnpm test covers the wikilink parser and slugger, the XSS-safe search
snippet renderer, presence subscription lifecycles, page create/save and
link reconciliation, the editor's flush-on-exit, workspace creation, and
static guards over the SQL (RLS enabled everywhere, pinned search_path,
SECURITY DEFINER on the policy helper and revision trigger, clamped RPC
parameters).
npm run test:db is the one that executes the migrations: it boots a
throwaway Postgres (or uses $DATABASE_URL), applies 0001→0008, and
asserts the real RLS behaviour — bootstrap ownership, revision-on-save,
owner reads, the search/graph RPCs, and full non-member/anon isolation. It
needs initdb/psql on PATH (e.g. brew install postgresql), and runs
in CI against a postgres:16 service (.github/workflows/ci.yml).
The app is a static Vite build; Supabase provides the database, auth, and
realtime. vercel.json already contains the SPA rewrite (deep links like
/p/some-page would 404 without it), asset caching, security headers, and a
Content-Security-Policy.
0. Pick the right branch. The application code lives on the
Inkwell-setup branch; main is only a README. When you import the repo
(step 4) set Vercel's Production Branch to Inkwell-setup, or merge
Inkwell-setup into main first. Otherwise production builds an empty tree.
1. Create the Supabase project. On supabase.com create a project and note, from Settings → API, the Project URL and the anon public key.
2. Apply the schema. Migrations 0001 → 0008, in order, either:
- CLI:
npx supabase link --project-ref <ref>thennpx supabase db push(runnpx supabase initfirst — the repo ships noconfig.toml), or - Dashboard: paste each file in
supabase/migrations/(0001 → 0008, in order) into the SQL editor and run it.
0008 is required, not optional — it fixes two row-level-security faults
(without it every signed-in user gets an error and every page save is
rejected) and enables Realtime for the page list. Don't run
supabase/seed.sql against production; its demo workspace has no members and
is invisible under RLS.
3. Configure Supabase Auth (Authentication in the dashboard):
-
URL Configuration → set Site URL to your production domain (
https://inkwell-yourname.vercel.app) and addhttps://<domain>/**to Redirect URLs. The/**matters: sign-in preserves the page you were on, and previews need their own pattern (e.g.https://*-<team>.vercel.app/**). -
Email → SMTP Settings → configure custom SMTP (Resend, Postmark, or SES). This is required for real users: Supabase's built-in sender is rate-limited to a few mails/hour and only delivers to your own team addresses, so strangers never receive their magic link. Set SPF, DKIM, and DMARC on the sending domain or the mail lands in spam.
-
Email Templates → Magic Link → include the code so the 6-digit fallback works when a corporate mail scanner consumes the link:
<p>Your Inkwell sign-in code: <strong>{{ .Token }}</strong></p> <p>Or click to sign in: <a href="{{ .ConfirmationURL }}">Sign in</a></p>
4. Import the repo into Vercel.
Add New → Project, pick the repository. The Vite preset is auto-detected
(npm run build → dist/); vercel.json pins it anyway. In Settings →
Environments, set the Production Branch to Inkwell-setup (step 0).
5. Set the environment variables (Settings → Environment Variables, for Production and — if you want working previews — Preview):
| Name | Value |
|---|---|
VITE_SUPABASE_URL |
Project URL from Settings → API |
VITE_SUPABASE_ANON_KEY |
anon public key from Settings → API |
The anon key is safe to expose to browsers — row-level security is the actual boundary. If a variable is missing the deployed app shows a setup hint instead of a blank page. Changing these later requires a redeploy (Vite inlines them at build time). Public previews point at the same database, so either use a separate Supabase project for Preview or enable Vercel deployment protection.
6. Deploy and sign in. Open the site, request a magic link (or use the code), and the app walks you through creating your first workspace — you become its owner automatically. Every later user just signs in and creates or is added to a workspace; an owner adds others via:
insert into workspace_members (workspace_id, user_id, role)
select w.id, u.id, 'editor'
from workspaces w, auth.users u
where w.slug = '<workspace-slug>' and u.email = 'them@example.com';7. (Recommended) Keep it alive and backed up. Free Supabase projects
pause after 7 idle days and have no automatic backups.
.github/workflows/supabase-keepalive-backup.yml handles both, twice a week,
once you add the repo secrets SUPABASE_URL, SUPABASE_ANON_KEY, and
(for dumps) SUPABASE_DB_URL. It is inert until those are set.
- Every table has row-level security; roles come from
workspace_members. The policy helper and the revision trigger areSECURITY DEFINERwith a pinnedsearch_path(seesupabase/migrations/0008), verified bynpm run test:db. - Search snippets render as text — only the
<mark>highlights fromsearch_pagesbecome elements, so authors can't inject HTML into other users' results.react-markdownruns withoutrehype-raw. - A Content-Security-Policy (
vercel.json) restricts scripts to'self'and connections to your Supabase project over HTTPS and WSS. It allows the default*.supabase.cohost; if you put Supabase behind a custom domain or self-host it, updateconnect-srcinvercel.jsonto that API/Realtime host or every request is blocked. - Signup is open: anyone can create an account and a workspace. Existing data stays protected by RLS, but if you want invite-only, disable email signups in Supabase. Consider enabling Supabase Auth CAPTCHA against email-bombing.
Pre-alpha, deployable. Working: page CRUD, wikilinks, backlinks, full-text search, graph view, presence, revisions, multi-user RLS, in-app onboarding, light/dark, mobile layout. Next: CRDT co-editing, Git mirror worker, workspace switcher UI.
src/
lib/ supabase client, wikilink parser, slugify, snippet renderer, theme
hooks/ data hooks (usePage, useWorkspace, useGraph, useSearch, …)
components/ Editor, PageList, BacklinksPanel, GraphView, SearchBar,
Onboarding, ThemeToggle, ErrorBoundary, AppShell, …
pages/ route components (HomePage, PageView, GraphPage, LoginPage)
test/ vitest setup, supabase mock, migration guard tests
supabase/
migrations/ 0001 pages → 0008 RLS fixes + realtime
seed.sql demo workspace + two pages (local dev only)
test/ real-Postgres migration + RLS integration test (npm run test:db)
.github/
workflows/ CI (lint/test/build + DB integration) and keep-alive/backup
vercel.json SPA rewrites, caching, security headers, CSP