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 necessarily better suited. For interaction-heavy applications, SSR and isomorphic runtime models introduce extra concepts that don't always translate into product benefits.
The final direction I settled on:
Public sites use SSR or SSG
Logged-in products use SPA
Business capabilities delivered through unified HTTP API
This article documents the specific decision-making process.
TanStack Start's Appeal
TanStack Start fits well with what I expect from a modern TypeScript framework.
It builds on TanStack Router and Vite, providing 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 doesn't require manually maintaining an HTTP client and return types. Server Functions behave like ordinary async functions on the client, with input and output types directly inferred.
For simple pages, this pattern flows smoothly.
Problems mainly arise when page interactions become more complex.
For example, if a list needs manual refresh, you can call:
router.invalidate()
If the list uses TanStack Query, it would be:
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 cache
If you continue adding Query Options, Mutation, TanStack Form, TanStack Table, and Zod, a regular feature can easily spawn 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 individual use, but combined, the framework code overhead for simple business logic becomes relatively high.
TanStack ecosystem doesn't require projects to be written this way. The problem is that it provides many capabilities, and 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 be valuable. For regular CRUD, they might feel overkill.
Next.js's Complexity at Runtime Boundaries
Next.js code is typically shorter.
Server Components can query the database directly:
export default async function ProjectPage() {
const projectList = await db
.select()
.from(projects)
return <ProjectList projects={projectList} />
}
Modify 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 rely primarily on server-side rendering. Page, data fetching, and modification logic can be placed close together, and initial code volume stays manageable.
As client-side interactions increase, 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 runtime models and framework rules.
For editors, workflows, and admin dashboards, pages often have significant 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 solution, but it's more oriented around organizing applications with server components and page rendering at the center, which doesn't fully match my current product shape.
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:
Reading data uses loader
Modifying data uses action
Page submission uses Form or fetcher
After an Action completes, React Router re-validates related Loaders, so regular form scenarios don't require additional Query Cache maintenance.
This approach suits form and route-driven applications well. Request, Response, Cookie, Session, and uploads all use standard Web APIs, and debugging paths are relatively clear.
When there are many in-page operations, Actions might 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)
}
You can also split them into independent Resource Routes.
React Router's code volume typically falls between Next.js and a full TanStack solution. It suits traditional web data flow, but for complex client-side cache and multiple components sharing remote state, projects may still end up introducing TanStack Query.
SPA's Data Flow Fits Product Backends Better
After comparing several full-stack frameworks together, I revisited the regular SPA.
The remote data flow in an SPA project is straightforward:
React Component
↓
TanStack Query
↓
HTTP API
↓
Business Logic
↓
Database
Query:
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 has only one source of remote data state.
Queries use useQuery
Modifications 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 sufficient.
Admin dashboards, AI tools, and workflow applications typically don't depend on search engine indexing for internal pages. Users stay logged in for extended periods after authentication, with main operations happening in the browser.
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:
/ Marketing 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
Public regions use 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:
- Dashboards
- 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 fits me best is:
repo/
├── apps/
│ ├── site/
│ ├── web/
│ ├── api/
│ ├── desktop/
│ └── worker/
│
├── packages/
│ ├── ui/
│ ├── contracts/
│ ├── db/
│ ├── shared/
│ └── config/
│
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
The responsibilities of each app are:
apps/site
Marketing site, blog, documentation, and public pages
apps/web
Logged-in SPA product
apps/api
Unified business API
apps/desktop
Tauri client, added as needed
apps/worker
Async tasks and AI Jobs, added as needed
Shared packages are kept restrained.
packages/ui holds base 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 schemas, migrations, and database clients.
Business code stays in the specific App by default. There's no extraction to packages/core during the project startup phase.
API Uses Hono
The API layer chose 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')
Called 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 complete 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.
I 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 keep only 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, will I use parameter injection or constructor injection.
I won't create a Repository, Service, and Mapper for each table just to maintain 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, you can fall back to the corresponding underlying commands.
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 layers are relatively conservative:
Node.js LTS
PostgreSQL
Drizzle ORM
Stable versions of Better Auth
Zod
Production database migrations
Cookie Session
Tool upgrade failures affect development efficiency, while auth and database upgrade failures can affect users and data. These two parts shouldn't use the same upgrade strategy.
Deployment
Splitting source code into multiple Apps doesn't mean production must maintain many services.
You can use the same domain:
example.com/
→ Site
example.com/app/*
→ SPA
example.com/api/*
→ API
Gateway routes by path:
/ → Site
/app/* → Web
/api/* → API
Authentication maintains same-origin cookies. When SPA calls /api, it doesn't need to handle cross-origin tokens. Desktop and other external clients use separate authentication methods.
For early-stage personal projects, 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 unified full-stack framework managing all capabilities. Instead, it places different types of pages back into the runtime models they're better suited for.
Public content uses SSR or SSG
Logged-in products use SPA
General business capabilities use HTTP API
The final tech stack is approximately:
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 chains while letting the business backend continue using the familiar SPA data flow.
Framework capabilities can be added as project needs require. For most tool-oriented products, maintaining clear client, API, and data boundaries is easier to maintain than simultaneously combining SSR, Loader, Server Function, and Query in every page.