CHAPTER 08 · FULL-STACK

Backend, Data & End-to-End Types

This is your home turf: HTTP APIs, databases, auth, background jobs. This chapter doesn't repeat backend basics; it explains what's different in TS full-stack: types shared between frontend and backend, database connections under serverless, and the new security model that comes with "a function is an endpoint".

⏱ ~60 minDeep (end-to-end types, connection model, authorization) Use (Hono, Drizzle, Better Auth) Aware (queues & workflows)
By the end of this chapter you should be able to:
  • Name the three ways to organize a TS backend and pick one for a project.
  • Define tables with Drizzle and explain how types flow from the database all the way to a UI component.
  • Explain the database connection problem under serverless and the common fixes.
  • Separate authentication from authorization and enforce authorization in the data-access layer.
  • Design non-blocking handling for slow work such as AI summaries.

Opening: renaming one field

Opening question

In a project with a Java backend and a separate frontend, the backend renames summary to abstract. The frontend isn't updated; after release the detail page shows blanks until users complain. The usual defenses are API docs, contract tests and integration testing. In a TS full-stack project, could the same change surface the moment you hit save?

Where the answer lies: yes. Because frontend and backend share one language and one repo, types can "flow" through imports from the database schema all the way to UI components: the table definition yields a row type, server function return types are inferred from queries, and the client's result types are inferred from the server functions. Change the source and every downstream use errors immediately. That's "end-to-end type safety". It doesn't replace runtime validation (Chapter 04), but it eliminates a whole class of "frontend and backend out of sync" bugs.

Three shapes of a TS backend

ShapeWhat it isExamplesGood for
Built into the meta-frameworkBackend code lives in the full-stack framework: server components, server functions, route handlersNext.js Server Actions / Route Handlers; TanStack Start createServerFnProducts with a single web frontend; least effort
Standalone API serviceA separate HTTP service the frontend calls with fetchHono, Fastify, Express, NestJSMultiple clients (web + mobile + third parties); independent scaling and deploys
Typed RPCOn top of a standalone service, clients call endpoints "like functions" and get typestRPC, oRPC, Hono RPC clientBoth sides in TS, deployed separately, types preserved

For most personal projects and early products, start with the first. When a second client appears (a mobile app or a public API), move business logic into a standalone service. To keep that migration easy: don't write business logic in framework-specific files; write it in plain TS modules and let the framework layer merely adapt.

Layering: the same as you know, only thinner

Components browser / server Adapter layer server fn / routes Service layer business rules · authz Data access Drizzle queries Database Where types come from and flow (against the request direction) ① schema.ts: table definition typeof links.$inferSelect ② service return types inferred from query results ③ data types in components inferred via server fn / loader Zod input schema validates at runtime and yields param types Rename a field in ① → every use in ② ③ errors at once
The top row is the request direction; the bottom row is the type direction. Types are defined at only two sources — the database schema and input schemas — and everything else is inferred.

It maps onto Spring's Controller → Service → Repository, usually thinner: no DI container (modules themselves are singletons), no DTO classes (inference plus Pick/Omit expresses which fields are exposed).

Hono: an API framework that runs everywhere Use

If you need a standalone API service, Hono is the most portable choice in 2026: built on Web-standard Request/Response, the same code deploys to Cloudflare Workers, Node, Bun, Deno and Vercel; small, fast and good at inference. Stylistically it's close to Express or Go's Gin.

Databases and ORMs Use

Choosing a database

Choosing an ORM: Drizzle or Prisma

DrizzlePrisma
DefinitionTables defined in TS codeA dedicated schema.prisma file that generates a client
Query styleSQL-like (select/where/join), plus a relational query APIObject-style API, more abstract
Edge / serverlessLightweight, fits Workers and D1 naturallyImproved in recent versions; check the specific adapter
Pick it whenYou know SQL and want transparency and lightnessTeam familiarity, mature tooling (Studio, migration workflow)

This guide uses Drizzle. As a backend developer you'll find it's essentially "typed SQL":

Migrations: after editing schema.ts, run drizzle-kit generate to produce SQL migration files (review and commit them), then apply with drizzle-kit migrate. When an agent changes the schema, require it to show you the generated SQL — destructive changes like dropping columns or changing types need your sign-off.

The connection problem under serverless Deep

A traditional service is one long-running process that opens a connection pool at startup and shares it across requests. Serverless functions may run as hundreds or thousands of instances at once, each opening its own connections — quickly exhausting Postgres's max connections (often just a few hundred). Common fixes:

Likewise, "create a global client at module top level" is a correct singleton in a long-running process, but on some serverless platforms it may share state across requests that shouldn't be shared. That's the "shape of the runtime" topic of Chapter 09.

Authentication and authorization Deep

The two words get mixed up, but you must think about them separately:

OptionKindCharacteristics
Better AuthOpen-source library, self-hostedTS-native; users and sessions in your own DB; plugins for OAuth, 2FA, organizations; runs on many platforms
ClerkHosted serviceReady-made sign-in UI and user management; priced by users; data at a third party
Auth.jsOpen-source libraryLong history, deep Next.js integration
Supabase AuthHosted (with the database)Combines with Supabase's row-level security (RLS)
The most common authorization hole: IDOR

Insecure Direct Object Reference (IDOR): the endpoint is DELETE /links/:id, the code only checks "user is logged in", then deletes by id. Any logged-in user can delete anyone's bookmarks. The defense is exactly how remove above is written: make "belongs to the current user" part of the query condition, rather than fetching first and comparing afterwards (which is easy to forget). Every agent task that modifies data should state its authorization rule explicitly.

Validation and error handling

Unlike Java, the TS community has no single convention for errors. A practical approach:

  1. Validate at the entry: parse all external input with Zod in the adapter layer; on failure return 400/422 with per-field messages.
  2. Expected business failures are return values: e.g. a discriminated union like { ok: false, reason: "duplicate" }, so the type system forces callers to handle each case.
  3. Unexpected errors are thrown; the framework's error handler logs them and returns 500 without exposing stack traces to clients.

Slow work: don't make users wait on the request Aware

LinkNote's "fetch page + LLM summary" can take 10–60 seconds. Doing it synchronously in the save request means the user stares at a spinner, the serverless function may time out, and a failure halfway means starting over. The standard approach is to go asynchronous:

① Save requestwrite status=pending ② Enqueuereturn 202 at once ③ Queue / workflowretries · steps · durable ④ Executefetch → LLM → write ⑤ NotifySSE / polling Cloudflare: Queues / Workflows Vercel: Queues / Workflow Third-party: Inngest, Trigger.dev Self-host: BullMQ + Redis
The user gets a response at step ②, and the UI shows "generating"; when the result is ready, a push or poll updates it.

"Workflows" (durable execution) are a popular upgrade: write the job as several steps, the platform persists each step's result, a failure retries only the failed step, and you can even "wait a few hours, then continue". Especially good for AI apps (multi-step calls, human approval).

Other common backend pieces: object storage (S3, Cloudflare R2, Vercel Blob) for files, usually with presigned URLs so browsers upload directly; key-value caches (Redis, Upstash, Cloudflare KV) for caching and rate limiting; cron jobs provided by the platform. Aware

Tell the agent
Vague
Precise
"Write a delete-bookmark endpoint."
"Add bookmark deletion: service function remove(userId, id) whose delete condition includes both id and userId (to prevent IDOR), returning whether a row was deleted. Adapter layer: 401 if not logged in, 400 if id isn't a UUID, 404 if nothing was deleted. Write a test for 'deleting someone else's bookmark' expecting 404."
"Make summarizing a background job."
"When saving a link, only write status=pending and enqueue a job; the endpoint returns 202 immediately. The consumer fetches the page (10s timeout) and calls the LLM; on success write done, on failure write failed with the reason; retry up to 3 times with exponential backoff. The job must be idempotent: re-running it for the same link id must not create duplicate data."

This chapter's vocabulary: end-to-end type safety, single source of truth, adapter / service layer, migration, connection pooling, authentication vs authorization, IDOR, idempotency, exponential backoff, async jobs / queues / durable workflows.

Interview corner
  • tRPC / server functions or REST? Serving only your own TS frontend, typed RPC is the most productive; for third parties or multi-language clients, REST (with OpenAPI) fits better.
  • What's the problem connecting to a DB from serverless? Many short-lived instances exhaust connections; use a pooling proxy, an HTTP driver, or Hyperdrive.
  • How do you make background jobs safe to retry? Make them idempotent: dedupe by business ID, check state before acting, write with upserts.

Self-check

1. You rename links.title to links.headline and rerun the type check. What errors? What doesn't error but will still break?

Errors: every service, server function and component accessing .title through inferred types. No errors: the database itself (it needs a migration to rename the column); places that bypass types with as or any; hand-written SQL strings; external systems (stale data in caches, third-party clients of your API). End-to-end types only cover code within the same compilation.

2. The agent's service layer does findById(id), then if (link.userId !== user.id) throw Forbidden, then deletes. Is that safe? How does it compare to putting the condition in the where clause?

Logically safe, with two weaknesses: it relies on every developer (and agent) remembering that if in every new endpoint; and "check" and "delete" are two operations with a time window between them (minor for deletes, a possible race for updates). Putting it in the where clause makes authorization part of the query itself, far harder to forget. Returning 404 instead of 403 also avoids leaking "this id exists".

3. A serverless app reports "too many connections" at peak traffic and is fine off-peak. Most likely cause?

At peak the platform spins up many function instances, each opening its own DB connection (or pool), exceeding the database's limit. Fix: switch to a pooled URL / serverless driver / Hyperdrive, and make sure each instance's pool size is tiny (e.g. 1).