My TypeScript Full-Stack Technology Trade-offs: The Boundaries Between SSR, SPA, and API
I've been organizing a TypeScript project template that can be reused over the long term.
Most of my projects are AI tools, admin dashboards, workflows, editors, and desktop companion apps. The tech stack centers on React, TypeScript, and Vite, with Node.js, PostgreSQL, and Drizzle on the backend.
Initially, I considered picking a React full-stack framework to unify routing, SSR, data fetching, API, and deployment. I focused on Next.js, React Router Framework Mode, and TanStack Start, while also keeping an eye on Remix 3.
After researching for a while, I realized that more complete framework capabilities aren't always better suited. For interaction-heavy applications, SSR and isomorphic runtime models introduce extra concepts that don't necessarily translate into product benefits.
The final direction I settled on:
Public sites use SSR or SSG
Logged-in products use SPA
Business capabilities provided through unified HTTP API
This article documents the specific decision-making process.
The Appeal of TanStack Start
TanStack Start aligns well with my expectations for a modern TypeScript framework.
Built on TanStack Router and Vite, it provides type-safe routing, Search Params, Loaders, Server Functions, Middleware, and SSR. Combined with TanStack Query, Form, and Table, it forms a complete frontend application development system.
A simple server-side query can be written as:
export const listProjectsFn = createServerFn()
.handler(async () => {
return db.select().from(projects)
})
Called directly in the route:
export const Route = createFileRoute('/projects')({
loader: () => listProjectsFn(),
component: ProjectsPage,
})
The component reads the result through the route:
function ProjectsPage() {
const projects = Route.useLoaderData()
return <ProjectList projects={projects} />
}
This approach eliminates the need to manually maintain HTTP clients and return types. Server Functions behave like regular async functions on the client, with input and output types automatically inferred.
For simple pages, this pattern flows smoothly.
The main issues arise when page interactions start getting more complex.
For instance, lists need manual refresh, which can be done by calling:
router.invalidate()
If the list uses TanStack Query, it would be written as:
query.refetch()
After adding a new record, you can re-execute the route Loader:
await router.invalidate()
Or invalidate the Query Cache:
await queryClient.invalidateQueries({
queryKey: ['projects'],
})
At this point, the project might have two data lifecycles running simultaneously:
Route Loader manages route data
TanStack Query manages client-side caching
If you continue adding Query Options, Mutations, TanStack Form, TanStack Table, and Zod, a typical Feature can easily spread across many files:
project.server.ts
project.functions.ts
project.queries.ts
project.mutations.ts
project.schema.ts
project.form.ts
project.columns.tsx
Each module has reasonable use cases on its own, but when combined, the framework code overhead for simple business logic becomes relatively high.
The TanStack ecosystem itself doesn't require projects to be written this way. The issue is that it provides so many capabilities that developers can easily lay out the complete solution early in the project.
For complex Dashboards, real-time data, infinite lists, and optimistic updates, these abstractions can pay off. For typical CRUD operations, they might feel overkill.
Next.js's Complexity Lies at Runtime Boundaries
Next.js code is typically more concise.
Server Components can query the database directly:
export default async function ProjectPage() {
const projectList = await db
.select()
.from(projects)
return <ProjectList projects={projectList} />
}
Mutation operations can be written as Server Actions:
'use server'
export async function createProject(
formData: FormData,
) {
await db.insert(projects).values({
name: String(formData.get('name')),
})
revalidatePath('/projects')
}
The page submits directly:
<form action={createProject}>
<input name="name" />
<button type="submit">创建</button>
</form>
This pattern works well for content sites, e-commerce, and pages that primarily rely on server-side rendering. Page layout, data fetching, and mutation logic can be placed close together, keeping initial code volume manageable.
As client-side interactions increase, the code gradually involves:
Server Component
Client Component
Server Action
Route Handler
Suspense
Cache invalidation
Serialization boundaries
Next.js's extra cost usually doesn't manifest as many wrapper functions, but rather in the runtime model and framework rules.
For editors, workflows, and admin dashboards, pages often have substantial local state, polling, real-time data, modals, and multi-region interactions. At this point, the boundary between Server Component and Client Component needs continuous maintenance.
Next.js remains a mature production-ready solution, but it leans more toward organizing applications around server components and page rendering, which doesn't fully align with my current product structure.
React Router's Data Model Is More Unified
React Router Framework Mode uses Loader and Action to organize route data.
export async function loader() {
return {
projects: await listProjects(),
}
}
export async function action({
request,
}: Route.ActionArgs) {
const formData = await request.formData()
return createProject({
name: String(formData.get('name')),
})
}
export default function ProjectsPage({
loaderData,
}: Route.ComponentProps) {
return (
<>
<ProjectList projects={loaderData.projects} />
<Form method="post">
<input name="name" />
<button type="submit">创建</button>
</Form>
</>
)
}
Its model is closer to traditional web development:
Loaders read data
Actions mutate data
Page submissions use Form or fetcher
After an Action completes, React Router automatically revalidates related Loaders, so typical form scenarios don't require additional Query Cache maintenance.
This approach suits form-driven, route-driven applications. Request, Response, Cookie, Session, and uploads all use standard Web APIs, making debugging paths relatively clear.
When there are many in-page operations, Actions may need to dispatch to different business logic based on intent:
switch (formData.get('intent')) {
case 'create':
return createProject(formData)
case 'archive':
return archiveProject(formData)
case 'delete':
return deleteProject(formData)
}
These can also be split into separate Resource Routes.
React Router's code volume typically falls between Next.js and a full TanStack solution. It suits traditional web data flows, but for complex client-side caching and multiple components sharing remote state, projects may still end up introducing TanStack Query.
SPA's Data Flow Better Suits Product Backends
After comparing several full-stack frameworks, I re-examined a typical SPA.
A SPA project's remote data flow is straightforward:
React Component
↓
TanStack Query
↓
HTTP API
↓
Business Logic
↓
Database
Querying:
const projectsQuery = useQuery({
queryKey: ['projects'],
queryFn: projectApi.list,
})
Manual refresh:
<button onClick={() => projectsQuery.refetch()}>
刷新
</button>
Refresh list after creation:
const createProjectMutation = useMutation({
mutationFn: projectApi.create,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['projects'],
})
},
})
This model maintains only one remote data state.
Queries use useQuery
Mutations use useMutation
Cache refresh uses invalidateQueries
Page parameters use Router Search Params
Local state uses useState or Jotai
For logged-in management systems, this model is already sufficient.
Admin dashboards, AI tools, and workflow applications typically don't rely on search engine indexing for internal pages. Users stay logged in for extended periods, with most operations happening client-side.
SSR's benefits on these pages are limited, but it adds complications around Hydration, server-client module boundaries, Loader lifecycles, and cache synchronization.
Therefore, I decided not to put the entire product into the SSR model.
Splitting SSR and SPA by Route Region
The final product routes can be divided into three regions:
/ Homepage
/pricing Pricing page
/blog/* Blog
/docs/* Documentation
/tools/* Public tool pages
/app/* Logged-in product
/app/projects/* Project management
/app/workflows/* Workflows
/app/analytics/* Data analytics
/api/* Business API
The public region uses SSR or SSG, handling:
- SEO
- Above-the-fold content
- Open Graph
- Blog and documentation
- Product introduction
- Public tool pages
The /app region uses SPA, handling:
- Dashboard
- Workflows
- Editors
- AI generation
- Real-time data
- Complex tables
- Multi-panel interactions
The API handles:
- Authentication
- Authorization
- Business logic
- Database access
- File uploads
- Webhooks
- Desktop and other clients
This way, each region only maintains one primary data model.
Final Monorepo Structure
The structure that currently suits me best is:
repo/
├── apps/
│ ├── site/
│ ├── web/
│ ├── api/
│ ├── desktop/
│ └── worker/
│
├── packages/
│ ├── ui/
│ ├── contracts/
│ ├── db/
│ ├── shared/
│ └── config/
│
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
Each app's responsibilities are:
apps/site
Website, blog, documentation, and public pages
apps/web
Logged-in SPA product
apps/api
Unified business API
apps/desktop
Tauri client, added when needed
apps/worker
Async tasks and AI Jobs, added when needed
Shared packages are kept restrained.
packages/ui holds basic components, style tokens, and animation presets.
packages/contracts holds Zod Schemas, error structures, and API types that truly need to be shared across clients.
packages/db holds Drizzle Schema, Migrations, and database Clients.
Business code stays in the specific App by default. We don't extract it into packages/core during the project's initial phase.
API Uses Hono
The API layer chooses Hono.
Its routing syntax is lightweight:
const app = new Hono()
const projectRoutes = app
.get('/projects', async (c) => {
return c.json(await listProjects())
})
.post(
'/projects',
zValidator('json', createProjectSchema),
async (c) => {
const input = c.req.valid('json')
const project = await createProject(input)
return c.json(project, 201)
},
)
The client can use Hono RPC:
const client = hc<AppType>('/api')
Calling in TanStack Query:
export function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: async () => {
const response =
await client.projects.$get()
if (!response.ok) {
throw new Error(
'Failed to load projects',
)
}
return response.json()
},
})
}
The full call chain is:
Component
↓
TanStack Query
↓
Hono RPC Client
↓
Hono Route
↓
Feature Function
↓
Drizzle
This API can simultaneously serve Web, Desktop, browser extensions, Agents, and Webhooks.
Compared to framework-internal Server Functions, HTTP API has a wider reuse scope.
Backend Organized by Feature
Splitting frontend and backend doesn't mean the backend needs to copy Java's layered approach.
We won't use this global directory structure:
controllers/
services/
repositories/
mappers/
dto/
APIs are still organized by Feature:
apps/api/src/features/project/
├── project.routes.ts
├── project.schema.ts
├── project.query.ts
├── project.command.ts
└── project.policy.ts
Simple Features only keep necessary files:
project.routes.ts
project.schema.ts
Database instances can be imported directly:
import { db } from '@repo/db'
Only when there are multiple implementations, complex tests, or runtime replacement needs do we use parameter injection or constructor injection.
We won't create a Repository, Service, and Mapper for each table just to maintain an architectural form.
Aggressive Tooling, Stable Runtime
Project tooling uses relatively new solutions:
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright
These tools don't directly handle production business data. Even if a version has compatibility issues, we can fall back to the corresponding底层 command.
Application layer uses:
React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
Among these, TanStack Form, Table, and Jotai are used on-demand, not as fixed dependencies for every page.
Data and security layer are relatively conservative:
Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth stable version
Zod
Formal database Migration
Cookie Session
Tool upgrade failures affect development efficiency, while auth and database upgrade failures may affect users and data. These two parts shouldn't use the same upgrade strategy.
Deployment
Splitting the source code into multiple Apps doesn't mean production must maintain many services.
We can use the same domain:
example.com/
→ Site
example.com/app/*
→ SPA
example.com/api/*
→ API
The gateway routes by path:
/ → Site
/app/* → Web
/api/* → API
Authentication maintains same-origin Cookie. When the SPA calls /api, there's no need to handle cross-origin Tokens. Desktop and other external clients use separate authentication methods.
For personal projects in early stages, Hono can also serve both API and SPA static files, keeping one Docker image and one Node process.
Conclusion
This technology selection didn't settle on a single framework that manages all capabilities holistically. Instead, it places different types of pages back into the runtime models they suit better.
Public content uses SSR or SSG
Logged-in products use SPA
General business capabilities use HTTP API
The final tech stack is roughly:
Site
Astro or other SSR / SSG solutions
Web
Vite
React
TanStack Router
TanStack Query
API
Hono
Drizzle
PostgreSQL
Better Auth
Zod
UI
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
Tooling
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vitest
Playwright
This structure preserves modern TypeScript tooling while keeping the business backend on the familiar SPA data flow.
Framework capabilities can be added as project needs require. For most tool-oriented products, maintaining clear boundaries between client, API, and data is easier to maintain than simultaneously combining SSR, Loader, Server Function, and Query in every page.