Data Flow
How a request moves through the stack from browser to database and back

Every request through this template follows one of two paths: a read or a write. Both pass through the same three layers in the same order, client → API → server and both hit the same service methods. What differs is the entry point and what happens after the database responds.
The read path
A read starts when a component mounts and its SWR hook fires a GET request
to a route handler in app/api/.
- The SWR hook in
hooks/callsfetch()against the route - The route handler validates the request with a Zod schema
- It delegates to a service method in
classes/services/ - The service runs a Drizzle
SELECTquery against Postgres - The result travels back up - service → route handler → SWR
- SWR caches the response and re-renders the component
SWR handles loading and error states automatically. Components never call
fetch() directly and never use useEffect for data fetching.
The write path
A write starts when a user submits a form. The component calls a server action
in actions/ - not a route handler.
- The component triggers a
POST / mutationvia a server action - The action validates the input with the same Zod schema the route uses
- It delegates to the same service methods the read path uses
- The service runs a Drizzle
INSERTorUPDATEagainst Postgres - On success, the action calls
revalidatePath()to invalidate the SWR cache - SWR re-fetches automatically and the component re-renders with fresh data
The key detail is step 5 - revalidatePath() is what closes the loop between
a server action and the client cache. Without it, the UI would show stale data
after a successful mutation.
Why reads and writes take different entry points?
Route handlers and server actions are not interchangeable. Each owns a specific surface and the split is intentional.
Route handlers (app/api/) own reads and external writes. They are standard
HTTP endpoints that any client can call.
Server actions (actions/) own UI writes. They run on the server but are
called directly from components, which means they can call revalidatePath()
to invalidate the SWR cache in the same request.
The practical rule is simple: if the mutation comes from our own UI, it uses a server action. If it comes from anywhere else, it uses a route handler.
External writes use the same schema
When a write does need a route handler (i.e. a webhook, for example), it validates with the same Zod schema the server action uses.
Concretely: src/validation/example-item.ts is the single source of truth for
example_items input validation. Both the server action and the route handler
import exampleItemSchema from that file, neither owns the rules.
// src/actions/example-item-actions.ts
import { exampleItemSchema } from '@/validation/example-item';
// src/app/api/example-items/route.ts
import { exampleItemSchema } from '@/validation/example-item';Why the same service for both paths?
Neither route handlers nor server actions query the database directly. Both
delegate to classes/services/.
Keeping all data access inside services means business logic lives in one place. The API layer stays thin: validate input, call the service, shape the response. If the same query is needed in both a route handler and a server action, the service method is simply called twice.
The update method on ExampleItemService is a concrete example. The PATCH
route handler and the updateExampleItem server action both need to update a
row and both simply call the same method:
// src/app/api/example-items/[id]/route.ts
const exampleItem = await exampleItemService.update(id, parsed.data);
// src/actions/example-item-actions.ts
const data = await exampleItemService.update(id, parsed.data);