TanStack Router: Opinionated Conventions for Production React Apps
This post captures a practical way to implement a React application using TanStack Router. It focuses on maintainability, predictable data flows, and ergonomics for both reading and writing code.
Note:
The conventions and recommendations in this guide are based on our real-world experience building a new internal application as the Sales Apps Team at MongoDB. They reflect lessons learned and best practices developed throughout that process.
Chosen tech stack
Typescript
Faster and safer development: Our goal was to catch bugs as early as possible—ideally at compile time, together with a next level developer experience. This approach not only reduces runtime errors but also makes refactoring and onboarding significantly easier.
Note:
In my experience, well-documented project or team conventions have an even greater positive impact on onboarding than end-to-end typing alone.
We can see some examples of next level code assistance enabled by TypeScript in tools like Arktype, TanStack Router, or openapi-typescript.
E2E type safe: Arktype + TanStack router + openapi-fetch
Any input to our application will be validated and typed, there are different data inputs that can affect our application state:
-
Search params: TanStack Router offers robust, type-safe search param validation out of the box. We use Arktype for runtime validation, which integrates seamlessly with TanStack Router—see this concise, powerful example.
-
Backend: In this case we generate a typed client from the backend OpenAPI spec with
openapi-fetch
. All backend interactions are handled exclusively through this generated client, ensuring type safety and consistency across the app.
Note:
For teams seeking an extra layer of runtime validation, tools like Arktype can be used to validate backend responses. However, in our experience—especially as maintainers of both the backend and frontend—this has proven unnecessary. With well-maintained OpenAPI specs, semantic versioning, and strong type generation, we've yet to encounter bugs caused by a mismatch between expected and actual responses. Unless your backend is managed by a separate team or you frequently encounter contract drift, runtime validation with Arktype is likely not needed in this case.
Testing: Vitest + React testing library
Here the choices are quite obvious:
- Vitest because it is the go-to testing framework if you are using Vite.
- Testing Library for intuitive, user-focused component tests.
Project conventions
Use search params to keep page state
Favor search params over component state for sharable, restorable URLs. This reduces ad‑hoc useState
and makes the page state linkable.
This convention is one of the easiest and most impactful decisions we have made:
- Being able for our users to share exactly the same data via URLs was really important and we get this for free.
- If we want to change the page state, we just navigate to change search params:
import { createFileRoute } from '@tanstack/react-router'
import { type } from 'arktype'
const productSearchSchema = type({
page: 'number = 1',
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: productSearchSchema,
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ deps: { page } }) => fetchProducts({ page }),
})
function Products () {
const navigate = Route.useNavigate()
function handleChangePage(page: number) {
// here we are just updating the query
navigate({ search: (prev) => ({...prev, page})})
}
}
When the navigation is performed, the loader will be called again to fetch the products in the new page. If the route was already loaded, then it will use the cached data.
Page state is fully type safe, always accessible, and can be as complex as needed. For example, this URL encodes all state to list products on page 2, 100 per page, sorted by ascending price, searching "water ski":
/shop/products?page=2&itemsPerPage=100&sort=asc&query=water ski&sortBy=price
Share this URL and everyone sees the exact same state.
If I want to change the state, will only have to navigate sort by date in descending order:
-`/shop/products?page=2&itemsPerPage=100&sort=asc&query=water ski&sortBy=price`
+`/shop/products?page=2&itemsPerPage=100&sort=desc&query=water ski&sortBy=date`
Interacting with the backend
Keep backend calls thin and predictable. Recommended approach:
- Generate a typed client from your OpenAPI spec (e.g., with
openapi-fetch
). - Wrap responses in a small helper that normalizes errors into a single
AppError
shape your UI can consume.
Example shape returned by processResponse
:
type AppError = {
title: string
description?: string
}
type Processed<T> = { data?: T; error?: AppError }
async function getProduct(productId: string) {
// response.data: only present if 2XX response
// response.error: only present if 4XX or 5XX response
const response = await client.GET("/products/{product_id}", {
params: {
path: { product_id: productId },
},
})
// Here is where we try to convert the error into an AppError unified interface
const { data, error } = processResponse({ response, action: `Fetching product ${productId}`})
return {
data,
error
}
}
Data fetching - Loaders
Leverage route loaders when possible because they simplify data flows:
- Avoid most data contexts/hooks for reads; when the route renders, data is ready to pass down
- Built‑in caching based on declared dependencies.
Exceptions in loaders
Throw in loaders (e.g., AppError
) so route error components handle failures consistently. Centralize error typing so UIs can show banners/toasts with title
and description
.
This is just convenient, because of TanStack Error Components.
Applying this pattern, fetching a product now throws on error for cleaner loader usage:
function getProduct(productId: string) {
// ... same code as in the example above, the only change is below
if (error) {
throw error
}
return data
}
Mutations
Mutation functions are similar to fetchers but generally should NOT throw exceptions. They’re invoked from event handlers, and throwing loses error type information. Return a { data, error }
object instead.
🚫 Ideally, we want to avoid having to inspect or discriminate error types at runtime:
try {
throw new NotFoundError('Not Found', 404);
} catch (error: unknown) {
if (error instanceof NotFoundError) {
console.log('The object was not found', error.objectId);
} else {
console.log(`Caught something else: ${error}`);
}
}
✅ Using the mutation in a route component:
async function handleDelete(productId: string) {
const { data, error } = await deleteProduct(productId)
if (error) {
// The error has a title and description attributes
pushToast({ variant: 'warning', ...error })
} else {
pushToast({
variant: 'success',
title: 'Deleted!',
description: `Product ${productId}`
})
// Invalidate so the loader re-fetches the page state
router.invalidate()
}
}
Styling
Use CSS Modules for simplicity. Create Component.module.css
next to Component.tsx
. Vite handles it out of the box, no extra config needed.
// HomeLayout.tsx
import classes from './HomeLayout.module.css'
export default function HomeLayout() {
return (
<div className={classes.homeContainer}>
<h2 className={classes.mainTitle}>Welcome!</h2>
</div>
)
}
Leverage nested routing to show nested components
If a nested component isn’t rendered by default (e.g., a modal), make it a nested route instead of toggling hidden UI state.
Benefits:
- Clear separation of concerns; no need to render hidden components
- Nested routes can access parent loader data
- Lazy loading is straightforward
- Recoverable app state via URLs (e.g.,
/products
vs/products/new
for opening a modal)
Avoid calling a loader when the parent already loaded the data
In nested routes, reuse parent data:
const { currentUser } = useLoaderData({ from: '__root__' })
Leverage the default router cache
The built‑in cache works well by default. After a mutation followed by navigation, you may want to router.invalidate()
to refetch fresh data.
Improve performance by setting only the needed loader deps
The dependencies are also part of the caching mechanism, and only the selected search params will re-fetch data.
const productsIndexSchema = type({
page: 'number = 1',
sort: '"newest" | "oldest" | "price" = "newest"',
condensedView: 'boolean = false',
})
// /routes/products.tsx
export const Route = createFileRoute('/products')({
validateSearch: productsIndexSchema,
loaderDeps: ({ search: { page, sort } }) => ({ page, sort }),
loader: ({ deps: { page, sort } }) =>
fetchProducts({
page,
sort,
}),
})
In this example, updating the state to display a more condensed view does not trigger a data refetch, since condensedView
is not included in the loaderDeps
. This means the loader will not re-run when only the view mode changes, optimizing performance by avoiding unnecessary network requests.
// This is not making the loader to re-fetch data, because condensedView is not defined in the loaderDeps
Route.navigate({ search: ({prev}) => ({...prev, condensedView: true})})
Using router history
Use history navigation when it reflects user intent (e.g., closing a modal):
- Simpler than reconstructing
navigate
calls with params and search state - Preserves cache/state more often; if you do need fresh data (e.g., post‑mutation), invalidate
Project structure
src
assets
: public assetscomponents
: React components; each usually has its own folder with styles and testshooks
: custom React hooksroutes
: TanStack Router routes (route tree definitions and pages)services
api
: wrappers around backend interactions; see fetch/mutation sectionsschemas
:arktype
schema definitions