Lightweight open source blog platform
Go to file
2026-07-05 16:11:21 -04:00
deploy Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
docker/initdb Build yap-blog platform 2026-07-02 21:32:33 -04:00
drizzle Add RSS feed, sitemap, robots.txt, and search-engine metadata 2026-07-05 13:59:04 -04:00
scripts Add author accounts with per-tag posting rights 2026-07-04 22:10:02 -04:00
src Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
tests Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
.dockerignore Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
.env.example Build yap-blog platform 2026-07-02 21:32:33 -04:00
.gitignore Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
AGENTS.md Initial commit from Create Next App 2026-07-01 20:11:08 -04:00
CLAUDE.md Initial commit from Create Next App 2026-07-01 20:11:08 -04:00
docker-compose.yml Build yap-blog platform 2026-07-02 21:32:33 -04:00
Dockerfile Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
drizzle.config.ts Build yap-blog platform 2026-07-02 21:32:33 -04:00
eslint.config.mjs Initial commit from Create Next App 2026-07-01 20:11:08 -04:00
LICENSE Add AGPL license 2026-07-05 16:11:21 -04:00
next.config.ts Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
package-lock.json Add AGPL license 2026-07-05 16:11:21 -04:00
package.json Add AGPL license 2026-07-05 16:11:21 -04:00
playwright.config.ts Build yap-blog platform 2026-07-02 21:32:33 -04:00
postcss.config.mjs Initial commit from Create Next App 2026-07-01 20:11:08 -04:00
README.md Add deployment setup and request rate limiting 2026-07-05 15:45:15 -04:00
tsconfig.json Initial commit from Create Next App 2026-07-01 20:11:08 -04:00
tsconfig.typecheck.json Build yap-blog platform 2026-07-02 21:32:33 -04:00
vitest.config.ts Build yap-blog platform 2026-07-02 21:32:33 -04:00

Yap Blog — a small self-hosted blogging platform

A self-hosted blogging platform built with Next.js (App Router) + React + TypeScript, backed by PostgreSQL via Drizzle ORM. One admin plus optional author accounts with granular permissions, a WYSIWYG editor with Markdown paste and typing shortcuts, moderated threaded comments, an RSS feed + sitemap + SEO metadata, JSON backup import/export, fifteen switchable themes, and twelve selectable body fonts — all managed from a session-authenticated admin area.

Stack DB ORM

Quick start

Requirements: Node.js ≥ 20 with npm 11 (package-lock.json is written by npm 11, and npm 10's npm ci rejects its layout — Node 24 bundles the right npm), Docker with the compose plugin, and free local ports 5434 (Postgres) and 3000 (dev server) — both configurable.

# 1. Configuration (set ADMIN_PASSWORD to taste)
cp .env.example .env

# 2. Database
docker compose up -d

# 3. Dependencies
npm install

# 4. Schema + demo content (admin credentials come from .env)
npm run db:migrate
npm run db:seed

# 5. Go
npm run dev

Open http://localhost:3000 for the public site and http://localhost:3000/admin for the admin area, signing in with the ADMIN_USERNAME / ADMIN_PASSWORD from your .env.

The seed is idempotent: the admin password hash is refreshed on every run (re-run it after changing ADMIN_PASSWORD), site settings are created only if missing, and demo content is only inserted into an empty database. It creates ~51 published posts (7 handwritten showcases plus generated topic filler so listings paginate to ~11 pages), 2 drafts, 9 tags, and 3 static pages.

Commands

Command Purpose
npm run dev Development server on :3000
npm run build Production build
npm start Serve the production build
npm run lint ESLint
npm run typecheck TypeScript compiler, no emit
npm test Unit + integration tests (Vitest, uses the blog_test DB)
npm run test:watch Vitest in watch mode
npm run test:e2e Production build, then Playwright E2E (uses the blog_e2e DB)
npm run test:all Both suites
npm run db:generate Generate a new migration from schema changes
npm run db:migrate Apply migrations
npm run db:seed Seed admin, settings, and demo content
npm run db:studio Drizzle Studio DB browser

The blog_test and blog_e2e databases are created automatically the first time the Postgres volume initializes (docker/initdb/). Both are wiped and re-migrated on every test run — never point them at data you care about. If you created the volume with an older setup, recreate it with docker compose down -v && docker compose up -d.

Routes

Route Purpose
/ Configured home page: all posts, one tag's posts, or a static page
/posts All published posts, newest first, paginated (?page=N)
/posts/[slug] One published post
/tags/[slug] Published posts with that tag, paginated
/pages/[slug] One published static page
/feed.xml, /sitemap.xml, /robots.txt RSS feed and crawler metadata
/admin Dashboard (auth required)
/admin/login Sign in
/admin/posts, /admin/posts/new, /admin/posts/[id]/edit, /admin/posts/[id]/preview Post management
/admin/pages, … Static-page management (same shape as posts)
/admin/comments Comment moderation queue
/admin/users, /admin/users/new, /admin/users/[id]/edit Author accounts, per-tag posting rights, permissions (admin only)
/admin/account Change your own password
/admin/settings Site title & URL, header/footer text, theme, font, navigation, home-page mode, pagination & excerpt limits, import/export

Architecture

src/
├── app/                  # Routes only — thin, no business logic
│   ├── (public)/         # Public site, wrapped in header/sidebar/footer chrome
│   │   ├── (home)/       #   / with its own loading skeleton
│   │   └── posts/(list)/ #   /posts with its own loading skeleton
│   ├── admin/login/      # Login (outside the guarded group)
│   └── admin/(panel)/    # Guarded admin area (layout + every page re-check auth)
├── actions/              # Server actions: auth, posts, pages, settings, preview
├── components/           # public/ and admin/ UI + shared primitives (ui.tsx)
├── db/                   # Drizzle schema + connection pool
├── lib/
│   ├── auth/             # scrypt hashing, session store, cookie, DAL guards
│   ├── services/         # All database access (posts, tags, pages, settings, home)
│   └── …                 # slug, markdown, excerpt, pagination, validation, forms
├── drizzle/              # Generated SQL migrations (committed)
├── scripts/seed.ts       # Idempotent seeding (also reused by the E2E setup)
└── tests/                # unit/ · integration/ · e2e/

Key decisions

  • Server components + server actions, almost no API layer. Public pages are React Server Components that call the service layer directly; admin mutations are server actions. The only route handlers are the ones a browser actually consumes as URLs — the RSS feed, sitemap/robots, image upload + serving, and the backup export download — and they sit on the same service layer as everything else.
  • A service layer owns all SQL. Files under src/lib/services/ are the only place queries live. Pages and actions stay thin, and the integration tests exercise the exact code paths production uses.
  • Everything renders dynamically (force-dynamic in the root layout). All content is admin-editable at runtime, so pages read the DB per request — plenty fast for an MVP and never stale. The obvious next optimization is tag-based caching (revalidateTag) around settings/posts.
  • Auth: opaque session tokens, scrypt passwords. Login verifies against a scrypt hash (Node's built-in crypto; parameters encoded per-hash so they can be raised later). Sessions are 32-byte random tokens in an httpOnly SameSite=Lax cookie; the database stores only the SHA-256 of the token, so a leaked DB dump cannot forge cookies. Expired sessions are treated as absent and purged on login. A failed login costs one scrypt derivation whether or not the username exists, avoiding a user-enumeration timing signal.
  • Protection is server-side at three layers: the admin layout redirects, every admin page calls requireAdmin() (React-cache()d per request), and every mutating server action calls it again — an action invoked directly over HTTP without a valid session cookie redirects instead of mutating. There is deliberately no middleware-only check to rely on.
  • Rich text editing, HTML storage. Posts and pages are written in a WordPress-style WYSIWYG editor (Tiptap/ProseMirror): headings, bold/italic/underline/strike, inline code, links, lists, blockquotes, code blocks, tables with row/column controls, horizontal rules, undo/redo — plus inline images uploaded straight from the editor (toolbar button, drag-drop, or paste). Pasting plain-text Markdown converts automatically through the same remark pipeline used for seeding; pasting rich HTML uses ProseMirror's native handling. Bodies are stored as HTML and pass through one shared rehype-sanitize allowlist (GitHub schema + <u>/table scaffolding) both on save and on render — scripts, event handlers, and javascript: URLs cannot survive either path, and the editor is WYSIWYG against the same .markdown-body styles the public site uses, so what you see is literally what publishes.
  • Slug policy. Slugs are generated from titles (NFKD-normalized, diacritics stripped, hyphenated). A generated slug that collides is auto-suffixed (-2, -3, …); an explicitly chosen slug that collides is rejected with a field error — the author picked it, silently renaming it would surprise them. Uniqueness is also enforced by DB constraints, so a race between two requests ends in a caught constraint error, not a duplicate.
  • Deletion safety is in the schema. settings.home_tag_id / home_page_id are ON DELETE SET NULL and nav items are ON DELETE CASCADE from their page, so deleting a tag or page featured on the home page or navigation degrades gracefully (home falls back to the post list; the nav item disappears). Unpublishing a page hides its nav items until it is republished. A CHECK constraint guarantees a nav item points at exactly one of URL/page.
  • HTTP 404 vs. streaming. Slug routes (/posts/[slug], /tags/[slug], /pages/[slug]) have no loading boundary so unknown or draft content returns a real HTTP 404. The two listing routes have loading skeletons, which means an out-of-range ?page= there streams a not-found UI with HTTP 200 plus a noindex meta tag (a "soft 404") — the deliberate trade-off for skeletons on the routes users actually wait on. Malformed pagination (?page=abc, -1, 1e9) clamps to page 1.
  • Theming. globals.css maps raw palette values to semantic tokens (--background, --ink, --link, …) and exposes those to Tailwind via @theme inline; components only ever use semantic utilities (bg-surface, text-ink-strong). The admin Settings → Appearance section switches the whole site (public + admin) between fifteen themes by stamping data-theme on <html>; each theme is nothing but a token override block. Dark: Solarized Dark (default), Dracula, Nord, Gruvbox Dark, Catppuccin Mocha, Tokyo Night, One Dark, Rosé Pine, Everforest Dark, Monokai, White on Black. Light: Solarized Light, Catppuccin Latte, GitHub Light, Black & White. The two mono themes are deliberately pure grayscale. Selection color, blockquote borders, badges, and the browser theme-color all derive from tokens, so new themes need no component work. Adding a theme = one CSS block, one enum value in src/db/schema.ts (+ generated migration), and one entry in the src/lib/themes.ts registry — the Record<Theme, …> type makes a missing entry a compile error.
  • Fonts. The admin also picks a site-wide body font: sans — Geist (default), Inter, Open Sans, Work Sans, Space Grotesk, Atkinson Hyperlegible; serif — Lora, Merriweather, Source Serif 4, EB Garamond, Playfair Display; mono — JetBrains Mono. All are self-hosted via next/font (downloaded once at build time, no runtime Google requests). Only Geist is preloaded; the rest are declared @font-face rules the browser fetches solely when data-font on <html> makes one active. Tailwind's font-sans resolves through --font-body, which each [data-font="…"] block remaps. Code blocks always stay in Geist Mono. The settings form previews each option in its actual typeface.
  • Accessibility. Semantic landmarks, labelled navs, a skip-to-content link, visible :focus-visible rings, aria-invalid/aria-describedby wiring on form errors, an Escape-closable mobile menu, and alt-text support (with an explicit "decorative" convention) on featured images. Destructive admin actions confirm before submitting.

Images

Inline images are uploaded from the editor to POST /api/admin/uploads (auth-required, 8 MB cap, MIME allowlist: PNG/JPEG/WebP/GIF/AVIF — SVG deliberately excluded). Files land in ./uploads/ (gitignored) under random UUID names — client filenames never touch the filesystem — and are served by the GET /uploads/[name] route handler with immutable cache headers (Next only serves public/ files that existed at build time, hence the route). The filename pattern is validated on read, ruling out path traversal. Alt text is editable per image via the toolbar's Alt button when an image is selected.

Featured images accept either a pasted URL or the same upload flow via the Upload button next to the field. Rendering uses a plain <img loading="lazy"> with an error fallback ("image unavailable") instead of next/image, because arbitrary admin-supplied URLs would require a wildcard remotePatterns, which turns the image optimizer into an open proxy.

Moving to object storage later: point saveUploadedImage (src/lib/uploads.ts) at S3/R2/MinIO instead of the local directory and return the bucket URL — the endpoint, editor, and schema stay unchanged. Restricting next/image to that bucket's hostname would then restore image optimization.

Error handling

Public and admin groups have scoped not-found.tsx; error.tsx shows a generic retry card (details stay in server logs, correlated by digest); global-error.tsx catches root-layout failures (e.g. DB down) with a self-contained page. Server actions return typed field/form errors — constraint violations and unexpected exceptions surface as friendly messages, never stack traces or connection strings. generateMetadata failures fall back to defaults rather than crashing the page.

Testing

  • Unit (tests/unit/): slugify + unique-slug suffixing, excerpt generation from stored HTML, both sanitizer pipelines (script stripping, event handlers, javascript: URLs, allowed editor marks), upload validation (MIME allowlist, size caps, filename generation), pagination parsing, URL validation, rate limiting (window rollover, per-key isolation, memory bound).
  • Integration (tests/integration/, real Postgres): draft exclusion from public queries, reverse-chronological ordering and pagination, publish/unpublish publishedAt semantics, duplicate-slug handling on create/update, tag visibility and filtering, home-page mode fallbacks after deletion/unpublication, nav resolution, password hashing, session lifecycle, comment threading/moderation, author permissions, import/export round-trips.
  • E2E (tests/e2e/, Playwright against a production build): admin routes redirect anonymously; bad credentials rejected; a full editorial flow — login → compose in the rich editor (heading + bold via toolbar) → publish → public listing/post/tag pages → draft 404s → logout locks the admin again; an editor-capabilities flow — markdown paste conversion, <script> stripped from pasted content, inline image upload through the toolbar, the uploaded file actually served, and the upload endpoint returning 401 anonymously; and an appearance flow (themes + fonts asserted via data-* attributes and computed styles).
npm test          # unit + integration (~7s)
npm run test:e2e  # build + 3 E2E scenarios (~1 min)

Deploying

The app is a standard Next.js server (npm run build + npm start) plus PostgreSQL — a small VPS runs both. Checklist for going live:

  1. Postgres. Point DATABASE_URL at a production database. The blog/blog credentials in docker-compose.yml are for local development — if you reuse the compose file on a server, change the password (and don't publish the port beyond localhost).
  2. Seed. Set a strong ADMIN_PASSWORD in .env, then npm run db:migrate && npm run db:seed. Passwords can be changed later from Admin → Account.
  3. Run. Two ready-made options:
    • systemddeploy/yap-blog.service runs npm start as a dedicated locked-down user; setup commands are in the unit file's header comment.
    • Docker — the Dockerfile builds a self-contained standalone image (non-root, uploads on a named volume, healthcheck). Build/run/migrate commands are in its header comment; migrations run from the tools build stage, since the runtime image has no dev dependencies.
  4. Reverse proxy + HTTPS. Serve behind nginx/Caddy/Traefik with TLS — the session cookie is Secure in production, so plain HTTP logins will not stick. Make sure the proxy sets X-Forwarded-For; the login and comment rate limits key on it.
  5. Site URL. Set Admin → Settings → Site URL (or the SITE_URL env var) so canonical URLs, the RSS feed, and the sitemap carry your real domain instead of localhost.
  6. Backups. Back up Postgres and the ./uploads/ directory (inline images live there). The JSON export on the settings page covers content and settings, but not uploaded files.

Out of scope (by design)

Public registration, search, analytics, and email are intentionally omitted.

Known limitations

  • Accounts are admin-created; there is no self-service password reset (the admin resets author passwords, and the admin password itself rotates via .env + re-seed).
  • Login and comment rate limits are in-memory and per-IP: they assume a single app instance and a reverse proxy that sets X-Forwarded-For (exposed directly, all traffic shares one bucket).
  • Every request hits the database (no caching layer yet — see the caching note above).
  • Images are unoptimized <img> tags by design (see Images); uploads live on local disk, so a multi-instance deployment needs the object-storage swap described above.
  • Upload validation trusts the declared MIME type (plus a strict extension map and SVG exclusion); magic-byte sniffing would be the next hardening step.
  • Out-of-range pagination on listing routes is a soft 404 (real 404s everywhere else).
  • window.confirm guards destructive actions and quick link/alt prompts; styled dialogs would be nicer.
  • Bodies are stored as editor HTML. Content from databases seeded before this change (markdown source) renders as plain text — reseed demo databases rather than migrating them.