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".
- 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
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
| Shape | What it is | Examples | Good for |
|---|---|---|---|
| Built into the meta-framework | Backend code lives in the full-stack framework: server components, server functions, route handlers | Next.js Server Actions / Route Handlers; TanStack Start createServerFn | Products with a single web frontend; least effort |
| Standalone API service | A separate HTTP service the frontend calls with fetch | Hono, Fastify, Express, NestJS | Multiple clients (web + mobile + third parties); independent scaling and deploys |
| Typed RPC | On top of a standalone service, clients call endpoints "like functions" and get types | tRPC, oRPC, Hono RPC client | Both 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
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
- Postgres: the default. Managed options include Neon and Supabase (both with free tiers); self-hosting is mature too. It can also do vector search via the pgvector extension (Chapter 11).
- SQLite: D1 on Cloudflare; also Turso (libSQL). Simple, cheap, a single file; good for "one database per user or tenant" architectures and small-to-medium apps.
Choosing an ORM: Drizzle or Prisma
| Drizzle | Prisma | |
|---|---|---|
| Definition | Tables defined in TS code | A dedicated schema.prisma file that generates a client |
| Query style | SQL-like (select/where/join), plus a relational query API | Object-style API, more abstract |
| Edge / serverless | Lightweight, fits Workers and D1 naturally | Improved in recent versions; check the specific adapter |
| Pick it when | You know SQL and want transparency and lightness | Team 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:
- Use the provider's pooled connection URL (e.g. PgBouncer mode).
- Use an HTTP- or WebSocket-based serverless driver (e.g. Neon's), so queries don't hold a long-lived TCP connection.
- On Cloudflare, use Hyperdrive: it maintains a connection pool to your database near the edge and caches queries.
- In a self-hosted long-running process, do what you know: create one pool at module level.
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:
- Authentication: who are you? — login, sessions, OAuth. Use a mature library; don't write it yourself.
- Authorization: may you do this? — does this bookmark belong to you? are you an admin? Only you can write this, because it is your business rules.
| Option | Kind | Characteristics |
|---|---|---|
| Better Auth | Open-source library, self-hosted | TS-native; users and sessions in your own DB; plugins for OAuth, 2FA, organizations; runs on many platforms |
| Clerk | Hosted service | Ready-made sign-in UI and user management; priced by users; data at a third party |
| Auth.js | Open-source library | Long history, deep Next.js integration |
| Supabase Auth | Hosted (with the database) | Combines with Supabase's row-level security (RLS) |
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:
- Validate at the entry: parse all external input with Zod in the adapter layer; on failure return 400/422 with per-field messages.
- 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. - 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:
"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
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."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.
- 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).