Build a Self-Hosted Notion Alternative
Build your own self-hosted note-taking and wiki app with Next.js, PostgreSQL and tiptap: editor, nesting, search and sync.
Build a private, self-hosted Notion alternative: a WYSIWYG editor, nested pages, full-text search and no subscription.
What we’re building
flowchart LR
User --> Next[Next.js]
Next --> Editor[tiptap editor]
Next --> PG[(PostgreSQL)]
User --> Search[Full-text search]
What you’ll learn
- A rich text editor that saves JSON, not HTML (diffable, migratable)
- Nested pages with recursive routes
- Postgres full-text search without an extra store
- A minimal single-user auth gate that actually protects the editor
Prerequisites
- Node.js 22+
- PostgreSQL 17
- 1 GB VPS (it’s just text)
1. Scaffold
npx create-next-app@latest wiki --ts --app --use-npm
cd wiki
npm i @tiptap/react @tiptap/starter-kit pg
npm i -D @types/pg
2. Schema
schema.sql:
CREATE TABLE pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES pages(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled',
body JSONB NOT NULL DEFAULT '{}',
pos INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON pages (parent_id);
CREATE INDEX ON pages USING gin (to_tsvector('english', title || ' ' || body::text));
docker-compose.yml (dev — the db service; the app and Caddy are added in section 8):
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: wiki
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up -d db
psql postgres://postgres:dev@localhost:5432/wiki -f schema.sql
To reset during development: docker compose down -v && docker compose up -d db and re-apply the schema.
3. Data layer
lib/db.ts — every function the guide references, with parameterized queries and typed errors. loadByPath walks the parent_id chain from the page name list:
import { Pool } from 'pg';
export const pool = new Pool({
connectionString:
process.env.DATABASE_URL ?? 'postgres://postgres:dev@localhost:5432/wiki',
});
export async function loadByPath(path: string[]) {
if (!path.length) return null;
// walk the chain: pages are addressed as /p/root/child/grandchild
const result = await pool.query(
`WITH RECURSIVE chain AS (
SELECT id, parent_id, title, body, 1 AS depth, ARRAY[title] AS names
FROM pages
WHERE parent_id IS NULL
UNION ALL
SELECT p.id, p.parent_id, p.title, p.body, c.depth + 1, c.names || p.title
FROM pages p JOIN chain c ON p.parent_id = c.id
)
SELECT id, title, body, names FROM chain WHERE names = $1::text[] LIMIT 1`,
[path],
);
return result.rows[0] ?? null;
}
export async function createPage(title: string, parentId: string | null) {
const result = await pool.query(
`INSERT INTO pages (title, parent_id) VALUES ($1, $2) RETURNING id`,
[title, parentId],
);
return result.rows[0];
}
export async function savePage(id: string, title: string, body: unknown) {
const result = await pool.query(
`UPDATE pages
SET title = $2, body = $3, updated_at = now()
WHERE id = $1
RETURNING id, updated_at`,
[id, title, JSON.stringify(body)],
);
if (result.rowCount === 0) throw new Error('Page not found.');
return result.rows[0];
}
export async function search(q: string) {
const { rows } = await pool.query(
`SELECT id, title FROM pages
WHERE to_tsvector('english', title || ' ' || body::text) @@ plainto_tsquery('english', $1)
ORDER BY updated_at DESC LIMIT 50`,
[q],
);
return rows;
}
4. Editor component
components/Editor.tsx — debounced autosave with a visible status (savePage in lib/db.ts receives the JSON body):
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
export function Editor({ pageId, initial }: { pageId: string; initial: string }) {
const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [status, setStatus] = useState('');
const editor = useEditor({
extensions: [StarterKit],
content: JSON.parse(initial),
onUpdate: ({ editor }) => {
setStatus('typing…');
clearTimeout(timeout.current);
timeout.current = setTimeout(async () => {
try {
const res = await fetch(`/api/pages/${pageId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: editor.getJSON() }),
});
if (!res.ok) throw new Error('save failed');
setSavedAt(new Date());
setStatus('');
} catch {
setStatus('save failed — retrying on next change');
}
}, 1000);
},
});
useEffect(() => () => clearTimeout(timeout.current), []);
return (
<div>
<EditorContent editor={editor} />
<span style={{ color: '#888', fontSize: 12 }}>
{status || (savedAt ? `Saved ${savedAt.toLocaleTimeString()}` : '')}
</span>
</div>
);
}
Saving JSON (not HTML) means the content survives future editor upgrades — the killer feature of self-hosted notes.
5. Nested pages
app/p/[...path]/page.tsx:
import { notFound } from 'next/navigation';
import { loadByPath } from '@/lib/db';
import { Editor } from '@/components/Editor';
export default async function Page({ params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const page = await loadByPath(path);
if (!page) return notFound();
return <Editor pageId={page.id} initial={JSON.stringify(page.body)} />;
}
app/api/pages/route.ts — create a page (nested under parentId when provided):
import { NextRequest, NextResponse } from 'next/server';
import { createPage } from '@/lib/db';
export async function POST(req: NextRequest) {
let body: { title?: string; parentId?: string | null };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Untitled';
const parentId = typeof body.parentId === 'string' ? body.parentId : null;
try {
const page = await createPage(title, parentId);
return NextResponse.json(page, { status: 201 });
} catch (err) {
return NextResponse.json(
{ error: `Create failed: ${err instanceof Error ? err.message : ''}` },
{ status: 500 },
);
}
}
app/api/pages/[id]/route.ts — autosave endpoint with validation:
import { NextRequest, NextResponse } from 'next/server';
import { savePage } from '@/lib/db';
type Ctx = { params: Promise<{ id: string }> };
export async function PUT(req: NextRequest, { params }: Ctx) {
const { id } = await params;
let body: { title?: string; body?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (typeof body.body !== 'object' || body.body === null) {
return NextResponse.json({ error: 'body: JSON object required.' }, { status: 400 });
}
try {
const updated = await savePage(id, body.title ?? 'Untitled', body.body);
return NextResponse.json(updated);
} catch (err) {
const message = err instanceof Error ? err.message : 'Save failed.';
const status = message === 'Page not found.' ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}
6. Full-text search
app/api/search/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { search } from '@/lib/db';
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q');
if (!q || !q.trim()) {
return NextResponse.json({ error: 'q query param required.' }, { status: 400 });
}
try {
const rows = await search(q.trim());
return NextResponse.json(rows);
} catch (err) {
return NextResponse.json(
{ error: `Search failed: ${err instanceof Error ? err.message : ''}` },
{ status: 500 },
);
}
}
Autosave is wired in components/Editor.tsx (1s debounce → PUT); the “Saved” indicator reads the component state.
7. Auth gate
Even for a single user, the editor must be protected. Minimal honest auth: one shared password → HMAC-signed cookie.
lib/auth.ts:
import crypto from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
const COOKIE = 'wiki_session';
export function sign(value: string, secret: string) {
return crypto.createHmac('sha256', secret).update(value).digest('hex');
}
export function verify(value: string | undefined, secret: string) {
if (!value) return false;
const [payload, sig] = value.split('.');
if (!payload || !sig) return false;
const expected = sign(payload, secret);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b) && payload === 'authed';
}
export function setSession() {
const res = NextResponse.redirect(new URL('/p/home', process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'));
res.cookies.set(COOKIE, `authed.${sign('authed', process.env.WIKI_SECRET!)}`, {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
secure: process.env.NODE_ENV === 'production',
});
return res;
}
middleware.ts — protects everything except the login page:
import { NextRequest, NextResponse } from 'next/server';
import { verify } from '@/lib/auth';
export function middleware(req: NextRequest) {
const secret = process.env.WIKI_SECRET!;
if (!verify(req.cookies.get('wiki_session')?.value, secret)) {
if (req.nextUrl.pathname.startsWith('/login')) return NextResponse.next();
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/login).*)'],
};
app/api/login/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { setSession, sign } from '@/lib/auth';
export async function POST(req: NextRequest) {
let body: { password?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (body.password !== process.env.WIKI_SECRET) {
return NextResponse.json({ error: 'Wrong password.' }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
const secret = process.env.WIKI_SECRET!;
res.cookies.set('wiki_session', `authed.${sign('authed', secret)}`, {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
secure: process.env.NODE_ENV === 'production',
});
return res;
}
app/login/page.tsx — the destination of the middleware redirect:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function Login() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
async function submit(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.ok) router.push('/p/home');
else setError('Wrong password.');
}
return (
<main style={{ maxWidth: 320, margin: '40px auto' }}>
<h1>Wiki login</h1>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Unlock</button>
</form>
{error && <p style={{ color: 'red' }}>{error}</p>}
</main>
);
}
Note: middleware.ts needs the Node.js runtime. In Next.js 15.2+ set export const runtime = 'nodejs' in the middleware file (crypto is a Node API). With that, auth works behind Caddy without extra config.
8. Run locally
docker compose up -d db
psql postgres://postgres:dev@localhost:5432/wiki -f schema.sql
export DATABASE_URL=postgres://postgres:dev@localhost:5432/wiki
export WIKI_SECRET=change-me
export NEXT_PUBLIC_APP_URL=http://localhost:3000
npm run dev
Test: open http://localhost:3000 (redirected to /login), POST the password, create a page, type into the editor and confirm “Saved” appears within ~1s. curl 'http://localhost:3000/api/search?q=notion' returns matching pages.
9. Deploy to a VPS
Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml (full — replaces the dev version):
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: wiki
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
app:
build: .
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/wiki
WIKI_SECRET: ${WIKI_SECRET}
NEXT_PUBLIC_APP_URL: https://YOUR-DOMAIN.com
depends_on:
- db
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
restart: unless-stopped
volumes:
pgdata:
caddy_data:
Caddyfile:
YOUR-DOMAIN.com {
reverse_proxy app:3000
}
On the VPS:
git clone <your-repo> wiki && cd wiki
cp .env.example .env # fill every value
docker compose up -d --build
psql "$DATABASE_URL" -f schema.sql # once
Point the DNS A record of YOUR-DOMAIN.com at the VPS IP; Caddy issues HTTPS automatically.
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. Locally postgres://postgres:dev@localhost:5432/wiki; on the VPS it points at the db service. |
WIKI_SECRET |
yes | Password that signs the session cookie (single-user auth gate). |
NEXT_PUBLIC_APP_URL |
no | Public origin used for redirects. |
Cost (assumptions: personal/team of 5, 10k pages)
| Service | Monthly cost |
|---|---|
| VPS 1 GB (Hetzner CAX11 ARM) | $3.79 |
| PostgreSQL (on VPS) | $0 |
| Total | ≈ $4/month |
vs Notion: $10–18/user/month.
Production checklist
- Auth gate active (
WIKI_SECRETset; middleware matcher covers all app routes) - Daily pg_dump (it’s all text — tiny)
- Version history per page (append-only table)
- WebDAV/caldav export if you want mobile access
- Backups tested once (
zcat backup.sql.gz | psql "$DATABASE_URL"into a scratch DB)
Common problems
- Search misses accents: use the
unaccentextension or lowercase-normalize. - JSON body blows up search: the
::textcast on JSONB is fine for small pages; switch to a separatebody_textcolumn for large docs. - Concurrent edits lost: last-write-wins is fine for personal use; for teams add an
updated_atconflict check. - Middleware fails on the VPS with “crypto is not defined”: ensure
export const runtime = 'nodejs'is inmiddleware.ts(Next.js 15.2+) — edge runtime lacks Node crypto. - Session rejects on every reload after deploy: cookie never set because
WIKI_SECRETdiffers between containers — keep it stable in.env.
Improvements
- Full-text search rank ordering with
ts_rank - Mermaid diagrams in the editor (tiptap has an extension)
- Browser PWA installability
- Page history: append-only
page_revisionstable, diff on restore
Conclusion
A self-hosted wiki is a Next.js app, a WYSIWYG editor and one PostgreSQL table. Four dollars a month replaces a subscription, and your notes are a plain SQL dump.