Tanstack Router + Query
As the best modern software combo

About me
- 💻 Fullstack Dev | 4+ years
- 🛠️ JS/TS, React, Next.js, Nest.js
- 🚴♂️ Gravel Biker
- ☕ Coffee Lover

Tanstack Router is the best React router and Tanstack Query is the best server state manager
- file based routing,
- nested routes & layouts,
- loaders per route,
- paralel data fetching,
- route caching and prefetching,
- error boundaries and handling.
Tanstack Router has
- Typesafe navigation
- Typesafe JSON-first Search Params,
- Middleware for search params,
- Granular cache invalidation,
- Client site persisted cache,
- Route data lifecycle API,
- No hosting vendor-lock,
Tanstack Router is better
Routing

Loading data
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
loader: async () => {
const posts = await getPosts();
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onError: sendInfoToSentry,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
});
const Posts = () => {
const { posts, categories } =
useLoaderData({ from: "/posts" });
return (
<div>
<DisplayPosts posts={posts} />
<Outlet />
</div>
);
};Search Params validation
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
loader: async () => {
const posts = await getPosts();
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
onError: sendInfoToSentry,
});
Search Params validation
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
loader: async () => {
const posts = await getPosts();
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
onError: sendInfoToSentry,
});
const PostsSearchSchema = z.object({
limit: z.number().min(1).max(100).default(10).optional(),
offset: z.number().min(0).default(0).optional(),
category: z.enum(["IT", "Business", "Marketing"]).optional(),
});Search Params validation
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
validateSearch: zodSearchValidator(PostsSearchSchema),
loaderDeps: ({ search }) => {
return {
limit: search.limit,
offset: search.offset,
category: search.category,
};
},
loader: async () => {
const posts = await getPosts();
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
onError: sendInfoToSentry,
});
const PostsSearchSchema = z.object({
limit: z.number().min(1).max(100).default(10).optional(),
offset: z.number().min(0).default(0).optional(),
category: z.enum(["IT", "Business", "Marketing"]).optional(),
});
Search Params validation
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
validateSearch: zodSearchValidator(PostsSearchSchema),
loaderDeps: ({ search }) => {
return {
limit: search.limit,
offset: search.offset,
category: search.category,
};
},
loader: async () => {
const posts = await getPosts();
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
onError: sendInfoToSentry,
search: {
middlewares: [
retainSearchParams(["limit", "offset"]),
stripSearchParams(["limit", "offset"]),
],
},
});
const PostsSearchSchema = z.object({
limit: z.number().min(1).max(100).default(10).optional(),
offset: z.number().min(0).default(0).optional(),
category: z.enum(["IT", "Business", "Marketing"]).optional(),
});
Search Params validation
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
validateSearch: zodSearchValidator(PostsSearchSchema),
loaderDeps: ({ search }) => {
return {
limit: search.limit,
offset: search.offset,
category: search.category,
};
},
loader: async ({ deps: { limit, offset, category } }) => {
const posts = await getPosts({ limit, offset, category });
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
onError: sendInfoToSentry,
search: {
middlewares: [
retainSearchParams(["limit", "offset"]),
stripSearchParams(["limit", "offset"]),
],
},
});
const PostsSearchSchema = z.object({
limit: z.number().min(1).max(100).default(10).optional(),
offset: z.number().min(0).default(0).optional(),
category: z.enum(["IT", "Business", "Marketing"]).optional(),
});
Search Params validation
// posts.tsx /posts
export const Route = createFileRoute("/posts")({
validateSearch: zodSearchValidator(PostsSearchSchema),
loaderDeps: ({ search }) => {
return {
limit: search.limit,
offset: search.offset,
category: search.category,
};
},
loader: async ({ deps: { limit, offset, category } }) => {
const posts = await getPosts({ limit, offset, category });
const categories = await getCategories();
return { posts, categories };
},
component: Posts,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 1 day
notFoundComponent: NotFound,
errorComponent: ErrorComponent,
pendingComponent: PendingComponent,
onEnter: sendToAnalyticsEnter,
onLeave: sendToAnalyticsLeave,
onError: sendInfoToSentry,
search: {
middlewares: [
retainSearchParams(["limit", "offset"]),
stripSearchParams(["limit", "offset"]),
],
},
});
const PostsSearchSchema = z.object({
limit: z.number().min(1).max(100).default(10).optional(),
offset: z.number().min(0).default(0).optional(),
category: z.enum(["IT", "Business", "Marketing"]).optional(),
modal: z.object({
isOpen: z.boolean().default(false),
postId: z.string().optional(),
action: z.enum(['create', 'edit', 'delete']).optional()
}).default({ isOpen: false }).optional()
});
Granular fetching & caching
// posts.$postId.tsx /posts/$postId
export const Route = createFileRoute("/posts/$postId")({
loader: async ({ params: { postId } }) => {
const post = await getPost(postId);
return post;
},
component: PostComponent,
});
const PostComponent = () => {
const { categories } = useLoaderData({ from: "/posts" });
const postData = useLoaderData({ from: "/posts/$postId" });
return ...
};Granular fetching & caching
// posts.$postId.tsx /posts/$postId
export const Route = createFileRoute("/posts/$postId")({
loader: async ({ params: { postId }, context: { queryClient } }) => {
await queryClient.ensureQueryData(postQueryOptions(postId));
await queryClient.ensureQueryData(categoriesQueryOptions());
},
component: PostComponent,
});
const PostComponent = () => {
const postId = useParams({ from: "/posts/$postId" }).postId;
const {
data: { author, content, id },
} = useSuspenseQuery(postQueryOptions(postId));
const { data: categories } = useSuspenseQuery(categoriesQueryOptions());
return ...;
};What to keep in mind?
-
No server-side*
-
SEO
-
Less popular than Remix and Next.js
-
Vendor lock-in ❔
-
JavaScript is required to run the page
Summary
- Typesafe JSON-first Search Params,
- Fully typesafe navigation,
- granular cache and invalidation,
- no forced abstraction,
Tanstack Router & Query
By Osergeevx Uvalentinau
Tanstack Router & Query
- 17