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 });
  }
}

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_SECRET set; 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 unaccent extension or lowercase-normalize.
  • JSON body blows up search: the ::text cast on JSONB is fine for small pages; switch to a separate body_text column for large docs.
  • Concurrent edits lost: last-write-wins is fine for personal use; for teams add an updated_at conflict check.
  • Middleware fails on the VPS with “crypto is not defined”: ensure export const runtime = 'nodejs' is in middleware.ts (Next.js 15.2+) — edge runtime lacks Node crypto.
  • Session rejects on every reload after deploy: cookie never set because WIKI_SECRET differs 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_revisions table, 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.

Related guides