Boring Docs

Project Structure — Where Everything Lives

Providers, libs, models, thin routes, pages, components, config, data, scripts.

Project Structure

First day in a new codebase, here's the question that matters: where does a feature live? In most repos the answer is "it depends, good luck" — auth logic smeared across middleware, a utils folder, and three components. Here the answer is always the same sentence: providers hold the capability, libs hold the verbs, routes stay thin, components stay dumb. Memorize that sentence and you'll never be lost. Let me show you why each half exists, because every folder here is a scar from something that went wrong somewhere else.

Why providers exist (the slop grenade story)

Give an autonomous agent a blank repo and ask for "user uploads." It invents utils/s3helpers.js, lib/storage2.js, and a third copy inside a component — three implementations, two of them broken, none of them tested. That's the slop grenade, and folder structure is the pin.

So capabilities live in providers/ — one folder per concern, one file per engine, identical exports everywhere:

  • providers/db/<engine>/ — sqlite, mongodb, supabase. Same exports on all three (User, Board, Post, … with { create, findById, list*, set* }), plugged through providers/db/index.js. The agent doesn't choose a shape; the shape is waiting.
  • providers/billing/<rail>.js — stripe, lemonsqueezy, dodo. Unified surface: createCheckoutSession, createPortal, verifyAndParse. Registry: providers/billing/index.js.
  • providers/billing/access.js — shared grantAccess / revokeAccess. The only place money touches users/orders. If revenue logic ever appears anywhere else, that's a bug, full stop.
  • providers/analytics/ — track() write path + stats.js read path on a separate analytics.sqlite, so your stats queries can never slow down checkout.
  • providers/storage/ — r2.js / s3.js behind one presigned-URL interface.

When you (or your agent) add a capability, you don't design — you fill in the template. New database? Same exports, one registry line. The structure does the architecting so nobody has to argue about it at midnight.

Why libs are verbs (and models are frozen)

libs/ holds runtime helpers, each named after what it does: paywall.js (requirePaidUser), security.js (safeUrl, rateLimit), telemetry.js (scream alerts), admin.js, blog.js, seo.js, voter.js, resend.js, next-auth.js. And libs/config.js holds the shared vocabulary — LIMITS, POST_STATUSES — the words every provider speaks.

models/*.js are frozen one-line re-exports over the registry. Never add logic there. I know it's tempting ("just one helper…") — that's how models/ becomes the junk drawer in every codebase that has one. The rule is absolute so nobody has to judge cases: re-export only, ever. libs/mongoose.js, same deal.

Follow one request (your orientation walk)

Don't read files top to bottom — trace one request and the map draws itself. Take creating a board:

  1. app/api/board/route.js — thin handler: validate input against LIMITS, call paidOrError() (401 ghost / 403 freeloader), one provider call, JSON. Under 40 lines. If you ever see a route doing data shaping, that's logic leaking upward — push it down.
  2. providers/db/index.js — delegates Board.create to the active engine. The route never knows which database it's talking to.
  3. providers/db/sqlite/boards.js (or mongo/supabase twin) — the SQL, the same shape as its siblings.
  4. Response flows back up, untouched. Down for data, up for answers — that's the whole architecture. Now do the same walk for checkout (app/api/billing/... → providers/billing/<rail>.js → access.js) and webhooks (route → verifyAndParse → grantAccess). Three walks and you own the codebase.
Active Engine:

Request Lifecycle

Down for data, up for answers.

Routes never touch SQL queries directly. One thin handler verifies auth, delegates to the provider registry, and returns plain JSON without ORM memory bloat.

  • ≤ 40 lines per route: input limits & session verification only
  • Tri-Database Parity: identical verbs across SQLite, Mongo & Supabase
  • Zero Leaking Drivers: results return as plain serializable JSON
  • Performance: 0.2ms latency · $0/mo
app/api/items/route.js
Step 2 of 4

Thin Route Handler

Validates input via LIMITS, checks user session via requirePaidUser(), and immediately delegates.

Strict Rule: ≤ 40 lines
// app/api/items/route.js (<= 40 lines)
export async function POST(req) {
  const user = await requirePaidUser();
  const body = await req.json();
  const item = await db.items.create(user.id, body);
  return Response.json(item);
}
Direct Data Path: Client ➔ Route ➔ DBZero npm bloat

The rest of the map

  • app/(pages)/<route>/page.js — public pages; private ones under dashboard/. Server components, data fetched at top, props flow down. No driver imports in pages.
  • components/<Name>.js — presentational only, Tailwind, props in JSX out. Client components never import DB code — if a component needs data, the page fetches and passes it. (components/<feature>/ once a feature grows 3+ pieces.)
  • config/ — domain configs (app, billing, auth, email, theme, services, paths, paywall, navigation, legal) re-exported through config/index.js and root config.js. Brand and business constants live here; runtime toggles live in libs/config.js. Two homes, no confusion.
  • data/blog/<slug>.md — posts. data/docs/*.md — these guides you're reading.
  • scripts/<verb>-<noun>.sh — wired as npm run <verb>:<noun>, sh-compatible, set -eu, usage comment on line 1.
  • nginx/<name>.conf — deploy targets with __PLACEHOLDERS__.
  • .agents/ + AGENTS.md — the agent operating manual. Agents read the same map you're reading now — that's why they don't invent folders.

Adding anything (the table you already saw, now with judgment)

Creating… Put it in Shape
New database providers/db/<name>/ + 1 line in registry connect, User, Board, Lead, Post, Vote, Comment, Product, Order
New entity Same exports in every engine + delegate + models/<E>.js shim { create, findById/getById, list*, set*, delete* } → plain { _id, id, … }
New rail providers/billing/<name>.js + 1 registry line createCheckoutSession, createPortal, verifyAndParse
New endpoint app/api/<thing>/route.js validate → auth → provider → JSON
New page app/(pages)/<route>/page.js server component, data at top
New UI piece components/<Name>.js props in, JSX out

No new top-level folders without updating CONVENTIONS.md — not because folders are sacred, but because every unmapped folder is a place the next agent will get lost. The map is the product as much as the code.

Symptoms that mean you left the map

You'll know you've drifted when: a route grows past 40 lines and starts shaping data (push it into the provider); a component imports from providers/db (lift the fetch to the page, pass props down); logic appears in models/ (move it to the provider it belongs to); two files do the same job with different names (delete one, keep the registry's). Each symptom has exactly one cure, listed right there. The codebase stays boring because deviations announce themselves loudly — listen to them early and you'll never need a "great refactor" weekend again.

Walk those three requests, then continue to 03-config-env — now you know where everything is, it's time to learn what turns it on.