← All observations

Field notes / 02 ·

One server.
Two modes. An API.

The next requirement was a server we could actually develop. Getting there exposed a gap in our workflow, a misleading 404, and a cache running yesterday’s instructions.

A miniature paper website pavilion opened to reveal a blue engine with brass gears and a coral flywheel.
A working engine, added when the job calls for one. A conceptual illustration, not a diagram of the server.

01 / The next requirement

Start small. Then add what is needed.

In our first field note, the website could already serve public pages. Static delivery was the first sufficient layer. It let us establish routing, HTML, styling and a production delivery path before taking on more behavior.

A permanently static website was never the goal. If serving handwritten HTML had satisfied every requirement, a simple file server would have been enough. We are building from the bottom up: keep what works, then add the capabilities that a real need makes necessary.

Minimalism has to account for the work the system must do.

The next need was server logic and a GraphQL API. That immediately exposed a mismatch: npm run dev started React Router’s development environment, while npm run start started our own Node.js server. The frontend had a development loop. The server effectively did not.

02 / One application entry point

The server needs a development mode too.

Express now owns the application entry point in both environments. GraphQL is mounted there in both modes. In development, Vite runs as middleware; in production, sirv serves built files and React Router handles requests through its server build.

DevelopmentExpress + GraphQL + Vite middleware. Server changes restart through tsx watch; Vite supplies the frontend development machinery.

ProductionExpress + GraphQL + built assets and the React Router server bundle. Traefik and Varnish remain in front of the application.

React Router now has ssr: true, alongside prerendering of the listed public pages. Request-time rendering is an accepted part of this runtime. Cacheable responses can still be served by Varnish without reaching Node.js on every request.

An early AI-assisted attempt split the work into three processes. That added coordination without addressing a requirement we actually had. One application entry point was enough. This does not remove the separate proxy and cache services; it keeps the application’s own development workflow coherent.

03 / The first API

A small query, a useful boundary.

Apollo Server and Pothos now provide a GraphQL endpoint at /api, with an embedded explorer for trying queries. Pothos builds the typed schema; Apollo handles GraphQL requests. There is no Prisma integration or database behind this first step.

query {
  health
}

# Response
{ "data": { "health": "ok" } }

This deliberately modest query establishes that the API is reachable through the production entry point. The playground is useful for inspecting the schema and making requests, but it is not the website’s data interface. We have not added the frontend API client yet.

04 / Integration costs

The seams are where things break.

Server compilation brought ESM resolution into view. Our source uses extensionless relative imports; the chosen Node-oriented TypeScript configuration objected to them. We currently bundle the server with esbuild and run TypeScript separately for checking. That resolves the immediate build problem without making this arrangement a universal recommendation. The production bundle import still has a known TODO.

Another small dependency detail mattered: the production start script uses cross-env. Keeping it in development dependencies meant a production-only install omitted something startup required. It now belongs to the runtime dependencies.

File-based routing answered a different structural need. Previously, the route list used string paths without import-level checks that the files existed. Deriving routes from the real file tree removes that manually maintained list. The benefit is a closer connection between routes and source files, not simply shorter configuration.

None of those changes eliminated the need to check HTTP behavior. After the server transition, our catch-all page said “Page not found” while returning 200. A visitor could see the intended message and a cache could still treat it as a successful response. The route now explicitly returns 404.

A miniature blue paper dispensing machine still handing out old cards while a fresh coral instruction card waits beside it.
The file had changed. The running cache had not. A conceptual illustration of stale configuration.

05 / A passing build, an old policy

The cache had not read the memo.

We excluded non-200 backend responses from Varnish caching and rebuilt the application. One test still failed: a repeated missing-page request returned X-Cache: HIT instead of MISS.

Inspecting the active VCL explained it. The updated file was on disk, but Varnish was still running the old configuration. Restarting the cache loaded the new policy. All five HTTP tests then passed. Rebuilding the application and applying cache configuration are separate operational steps.

The current policy gives public HTML up to one hour and matching asset URLs up to seven days. That is an interim policy. Cookie handling, personalized responses and publication invalidation remain open work; this configuration should not be treated as a finished policy for authenticated pages.

We want to reason about caching from the content and its dependencies. Varnish ultimately receives an HTTP response, so component-level needs must become a policy for that response, or for separately fetched data. How we will compose those policies is still undecided.

06 / Evidence at this revision

Five checks, with clear limits.

Type checking passed. After rebuilding the application and restarting Varnish, the local production HTTP checks verified:

  • Public HTML contains the expected titles and headings.
  • Unknown pages and missing assets return uncached 404 responses.
  • HEAD works, and an unsupported page POST returns 405.
  • Repeated page and asset requests hit Varnish under the current TTL limits.
  • A GraphQL POST returns the expected health response.

Those are useful observations, not proof of every browser interaction or a complete dynamic caching strategy. We did not repeat the earlier release’s load test or Lighthouse measurements for this checkpoint. These HTTP tests do not verify hydration or frontend data fetching.

The versioned HTTP tests and changes since v0.1.0 provide the implementation record behind this note.

07 / The next piece

The browser still needs its connection.

Next comes the frontend API client and a real data-driven interaction. We need to decide how requests, loading states, failures and client caching fit into the UI. We also need to keep server-rendered data and the initial client render consistent, then check subsequent updates.

The runtime and API give us somewhere to connect. They do not finish that work for us. The next useful demonstration is a request that changes something a visitor can see, with behavior we can explain and verify from server to browser.

Written against v0.2.0. Illustrations are conceptual; technical claims refer to the linked project snapshot.

← Back to the field notes