# 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](https://img.shields.io/badge/Next.js-16-blue) ![DB](https://img.shields.io/badge/PostgreSQL-17-blue) ![ORM](https://img.shields.io/badge/Drizzle-0.45-blue) ## 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. ```bash # 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 for the public site and 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 + ``/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 ``; 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` 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 `` 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 `` 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, `