Back to all articles

SSR or control: how I pick a React framework and a TypeScript backend

React Router v8 Framework Mode versus Next.js App Router, and why I pick Fastify as my starting TypeScript API, in my own decision notes

In this article

Why I needed to make this decision

I was picking a stack for a side project. The problem was not which framework was better, but how much scope I wanted the framework to decide by default. Every extra integration — SSR, data mutations, API boundaries, authentication, and deployment — might make delivery faster, but it can also leave me with fewer control points

So I split the question in two: should the React web app let a framework take over server work, and should the TypeScript API use a batteries-included application framework? Comparing those separately helped me see that I was choosing defaults, not looking for one answer for every project

React Router v8 Framework Mode versus Next.js App Router

React Router v8 Framework Mode and Next.js App Router can both handle SSR, SSG, and data mutations. The difference is how much each framework includes by default

React Router centers on route loaders for reads, actions for writes, and HTTP request/response objects. The official description of Framework Mode is a complete mode provided through a Vite plugin, with SSR, SPA, static rendering, code splitting, and route type safety. It fits teams that value a clear data flow, portable deployment, and the ability to choose their own cache, API, and server boundaries

Next.js App Router centers on Server Components, Server Actions, route handlers, and the framework’s cache/revalidation model. It offers broader built-in integration, which fits teams that want to follow its conventions, ship quickly, and lean into the wider React ecosystem and Vercel integration

Looking at the difference through a product page

Suppose I am building /products/:slug. React Router uses a loader on the server to fetch the product and then render the HTML; an admin update goes through an action, which writes the data and redirects afterward

Next.js uses a Server Component to fetch the product on the server and render the HTML. Updates commonly use a Server Action, followed by revalidatePath according to the cache strategy, or an explicit design for dynamic data behavior

The key point of SSR is that the first response already contains HTML with the product data. It is not whether the app has CRUD

Routes are configuration, not folder magic

React Router Framework Mode explicitly maps URL patterns to route modules in app/routes.ts. Folders are not magic, and they are not a mandatory convention

route("products", "./routes/products-list.tsx")
route("products/:slug", "./routes/product-detail.tsx")
route("admin/products/:id/edit", "./routes/admin-product-edit.tsx")

A route module commonly contains a loader() for data needed before SSR, an action() for form POSTs or mutations, and a default component for the page UI. I can organize nested folders by domain, or point multiple URLs at the same module, but a production product should usually split list, detail, and edit routes instead of turning one module into a huge switch

Nested URLs can use <Outlet /> to place child pages, while the parent route keeps shared layout and data boundaries

How I split things when SSR is not needed

If the web app does not need SEO or SSR, I start with simpler boundaries

web: React + Vite + React Router(Declarative 或 Data Mode)
api: 獨立 API service
worker: queue / cron / sync(需要才建立)

At that point React Router is just the front-end router, and the backend API becomes a contract shared by the web app, mobile clients, internal tools, and workers. If a product page really needs SSR, I can move to React Router Framework Mode + Vite while keeping the API separate

What Fastify actually is

Fastify is a complete HTTP/API framework, not just a library. Routing, hooks/plugins, JSON Schema validation, response serialization, structured logging, and error handling are all part of its core

Fastify deliberately does not impose a whole application stack. The project still chooses its ORM, migrations, OAuth, authorization, and directory structure. That gives me flexibility and a smaller default attack surface, but it also means I need a fixed starter so every project does not repeat the same selection exercise

I would start with this functional-first starter:

Fastify       API / plugins / structured logging
Zod           request validation
Drizzle       TypeScript-to-SQL ORM + migrations
PostgreSQL    database
Better Auth   login/session/OAuth(有需求才加入)

Drizzle stays close to SQL, uses TypeScript schemas to work with the database, and manages migrations. It is not centered on class-based Active Record models

Better Auth handles password hashing, sessions/cookies, OAuth callbacks, and account linking. Authentication — who you are — is not the same thing as authorization — what you are allowed to do

Both Zod and TypeBox can validate requests. If I already know Zod, I would keep using it; TypeBox maps more directly to JSON Schema and fits Fastify’s schema workflow more closely

Batteries-included options

AdonisJS is the Node/TS application framework that comes closest to Laravel. It integrates validation, auth, OAuth, Lucid ORM, migrations, and more, at the cost of stronger conventions and a commonly class-based controller/model style

NestJS puts more weight on architecture, decorators, DI, and classes, while ORM, auth, and validation still mostly require separate choices. That does not fit my preference for fewer classes, fewer decorators, and less selection work

Fastify best matches a functional-first, control-first approach, but I would turn the fixed combination into a starter template so flexibility does not mean starting from zero every time

An OAuth provider can never be zero-configuration. I still need to create a client ID with Google, Microsoft, or GitHub, then configure a callback URL and scopes. I have experienced an Auth0 outage, so I am not putting it on the candidate list this time

My quick decision notes

想快速採用完整 React 懶人包 → Next.js
想 SSR 但維持清楚、可控的 HTTP/route 模型 → React Router v8 Framework Mode + Vite
前後分離的純內部 SPA → React + Vite + React Router + 獨立 Fastify API
不想 classes/decorators,又要 TS API → Fastify functional-first starter

References

Related posts

Putting my home Obsidian vault behind Cloudflare Access as a remote MCP

I turned my Mac mini Obsidian vault into a remote MCP with Cloudflare Tunnel and Access, got Claude connected, and hit a DCR wall with Gemini while drawing clear security boundaries

Hello! I'm Josh!

A self-introduction that isn't quite a résumé — I'm using this space to collect some recent thoughts on AI, life, and travel.