Skip to content
Sean Marroquin
Esc
  • HomePage
  • WorkPage
  • AboutPage
  • Start a projectPage
  • How this site is builtPage
  • BlogPage
  • DownloadsPage
  • Client PortalClients
  • TerraLoopCase study
  • ListoCase study
  • The Listening RoomCase study
  • PasslineCase study
  • AccuBetsCase study
  • How this site is builtPost
  • Why every client gets a portalPost
  • Switch between light and darkAction
  • Copy my email addressAction
  • PWNTT ArcadePlay
  • Play The Deploy TrailPlay
  • Play Merge ConflictPlay

Use the arrow keys to move and Enter to open.

All posts

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:

AreaPathWho sees it
Public site/, /work, /blogEveryone
Client portal/portalSigned-in clients
Admin/adminMe
Arcade/arcadeEveryone 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.