How this site is built
2 min read
This site is three things in one codebase: a public site, a client portal, and an admin area I use to run projects. Here is what it is made of and why.
One app, clear boundaries
Everything is a single Next.js application, split into route groups that do not import from each other:
| Area | Path | Who sees it |
|---|---|---|
| Public site | /, /work, /blog | Everyone |
| Client portal | /portal | Signed-in clients |
| Admin | /admin | Me |
| Arcade | /arcade | Everyone with a few spare minutes |
One app means one deploy, one set of dependencies, and shared components. The boundaries mean any area can be split out later without a rewrite.
Static first, dynamic where needed
Public pages are prerendered and served as static HTML. Portal pages send a static shell immediately and stream each client's data into it. A page declares what is dynamic by reading the session:
export default function RequestsPage() {
return (
<PortalPage title="Requests">
<Suspense fallback={<ListSkeleton />}>
<RequestList />
</Suspense>
</PortalPage>
);
}
async function RequestList() {
const { organization } = await requirePortal();
const requests = await listRequests(organization.id);
// ...
}requirePortal() is the only way to learn which client is signed in, and every query takes the
organization id it returns. A client cannot ask for another client's data, because there is
nowhere to put someone else's id.
Data
PostgreSQL holds everything except files. Each table that belongs to a client carries an
organization_id, which was the one decision that would have been painful to retrofit.
Files live in object storage. The browser uploads and downloads through links that expire, so the bucket itself is never public.
Money
Stripe is the source of truth. I create invoices through its API, clients pay on Stripe's hosted page, and webhooks update a read-only copy in my database. Card numbers never reach my server.
Sign-in
Clients sign in with an emailed link. There are no passwords to forget, reuse, or leak, and accounts are invite-only.
Hosting
The app runs in Docker on a small virtual server, behind Caddy for HTTPS. Images are built in CI, so the server only ever pulls and runs them. The database is backed up nightly to object storage.
It is a deliberately plain setup. The parts are standard, each one can be replaced on its own, and the same code runs on a managed platform if the site ever needs it.