Integrations
TanStack Start
Set up raf-query with TanStack Router and Start, start queries in loaders, stream them during SSR and handle redirects.
TanStack Router's loaders are where raf-query wants queries to start, and its SSR streaming is what carries the cache to the browser. The integration is one function call.
Setup
import { class QueryClient<TServices = never>QueryClient, function QueryClientProvider(props: {
readonly children?: React.ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider } from "@raf-query/react";
import { function QueryDevtools(props: QueryDevtoolsProps): React.JSX.Element | nullQueryDevtools } from "@raf-query/react/devtools";
import { function setupRouterIntegration<TRouter extends AnyRouter>(options: RouterIntegrationOptions<TRouter>): voidsetupRouterIntegration } from "@raf-query/react/tanstack-router";
import { const createRouter: CreateRouterFncreateRouter } from "@tanstack/react-router";
import { const routeTree: Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>
routeTree } from "./routeTree.gen.ts";
export function function getRouter(): RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
getRouter() {
const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient();
const const router: RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
router = createRouter<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>(options: RouterConstructorOptions<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>): RouterCore<...>
createRouter({
context: {
queryClient: QueryClient;
}
context: { queryClient: QueryClient<never>queryClient },
defaultPreload?: false | "intent" | "viewport" | "render" | undefineddefaultPreload: "intent",
// The query cache decides what's fresh; loaders always run and dedupe there.
defaultPreloadStaleTime?: number | undefineddefaultPreloadStaleTime: 0,
routeTree?: Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined> | undefined
routeTree,
scrollRestoration?: boolean | ((opts: {
location: ParsedLocation;
}) => boolean) | undefined
scrollRestoration: true,
Wrap?: ((props: {
children: any;
}) => React.JSX.Element) | undefined
Wrap: (props: {
children: any;
}
props) => (
<function QueryClientProvider(props: {
readonly children?: React.ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider client: QueryClient<unknown>client={const queryClient: QueryClient<never>queryClient}>
{props: {
children: any;
}
props.children: anychildren}
{import.meta.ImportMeta.env: ImportMetaEnvenv.ImportMetaEnv.DEV: booleanDEV ? <function QueryDevtools(props: QueryDevtoolsProps): React.JSX.Element | nullQueryDevtools /> : null}
</function QueryClientProvider(props: {
readonly children?: React.ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider>
),
});
setupRouterIntegration<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>>(options: RouterIntegrationOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>>): void
setupRouterIntegration({ RouterIntegrationOptions<TRouter extends AnyRouter>.queryClient: QueryClient<never>queryClient, RouterIntegrationOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, "never", false, RouterHistory, Record<...>>>.router: RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
router });
return const router: RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
router;
}Mount DevTools only when import.meta.env.DEV is true so Vite drops the panel from production builds. The example app always renders it, so the panel stays available in the deployed demo.
Type the router context in the root route, so loaders see queryClient:
import type { class QueryClient<TServices = never>QueryClient } from "@raf-query/react";
import { function createRootRouteWithContext<TRouterContext extends {}>(): <TRegister = Register, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record<string, any> = {}, TLoaderFn = undefined, TSSR = unknown, TServerMiddlewares = unknown>(options?: RootRouteOptions<TRegister, TSearchValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares>) => RootRoute<TRegister, TSearchValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, unknown, unknown, TSSR, TServerMiddlewares, undefined>createRootRouteWithContext } from "@tanstack/react-router";
export const const Route: RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>
Route = createRootRouteWithContext<{
queryClient: QueryClient;
}>(): <TRegister, TRouteContextFn, TBeforeLoadFn, TSearchValidator, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares>(options?: RootRouteOptions<TRegister, TSearchValidator, {
queryClient: QueryClient;
}, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, undefined> | undefined) => RootRoute<...>
createRootRouteWithContext<{ queryClient: QueryClient<never>queryClient: class QueryClient<TServices = never>QueryClient }>()(
{},
);getRouter runs once per request on the server and once in the browser, so each request gets its own client and the browser keeps one for the session.
What the integration does
setupRouterIntegration:
- Streams queries during SSR. Entries go to the browser as they settle, through the router's own stream. Pending queries cross as promises.
- Clears the request's cache when the response finishes.
- Turns
redirect()thrown from aqueryFninto a navigation, wherever the query failed: a loader, a render or a background refetch in the browser.
Options:
| Option | Use |
|---|---|
dehydrateOptions | filter which entries are sent, keepErrors, serializeData |
hydrateOptions | deserializeData, the counterpart of serializeData |
handleRedirects | false to handle redirects yourself |
Loaders
A loader starts every query its route needs and returns the handles:
import { type interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle, function useQuery<TFactory extends QueryFactory, const TThrow extends boolean | undefined = undefined>(factory: TFactory, handle: QueryHandleWithData<TFactory>, options?: UseQueryOptions & UseQueryThrowOptions<TFactory> & {
readonly throwOnError?: TThrow;
}): UseQueryResultWithData<TFactory, TThrow> (+1 overload)
useQuery } from "@raf-query/react";
import { function createFileRoute<TFilePath extends keyof FileRoutesByPath, TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]["parentRoute"], TId extends RouteConstraints["TId"] = FileRoutesByPath[TFilePath]["id"], TPath extends RouteConstraints["TPath"] = FileRoutesByPath[TFilePath]["path"], TFullPath extends RouteConstraints["TFullPath"] = FileRoutesByPath[TFilePath]["fullPath"]>(path?: TFilePath): FileRoute<TFilePath, TParentRoute, TId, TPath, TFullPath>["createRoute"]createFileRoute } from "@tanstack/react-router";
import { const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense, function use<T>(usable: React.Usable<T>): Tuse } from "react";
import { function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton, function renderComment(comment: PostComment): React.JSX.ElementrenderComment } from "~/components/comments.tsx";
import { function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>
commentsQuery, function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery } from "~/queries.ts";
export const const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route = createFileRoute<"/posts/$slug", Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, "/posts/$slug", "/$slug", "/posts/$slug">(path?: "/posts/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute("/posts/$slug")({
FilebaseRouteOptionsInterface<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async (args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args) => {
const const qc: QueryClient<never>qc = args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args.LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.context: {
queryClient: QueryClient;
}
context.queryClient: QueryClient<never>queryClient;
const { const slug: stringslug } = args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args.LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.params: {
slug: string;
}
params;
// Never waits: the comments load while the page renders.
const const comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments = const qc: QueryClient<never>qc.QueryClient<never>.prefetch<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>, input: {
slug: string;
}): QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
prefetch(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>
commentsQuery, { slug: stringslug });
// Waits only if the post isn't in the cache yet.
const const post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post = await const qc: QueryClient<never>qc.QueryClient<never>.ensure<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, input: {
slug: string;
}): Promise<QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>> (+1 overload)
ensure(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, { slug: stringslug });
return { comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments, post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post };
},
UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage,
});
function function PostPage(): React.JSX.ElementPostPage() {
const const data: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
data = const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route.RouteExtensions<"/posts/$slug", "/posts/$slug">.useLoaderData: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, unknown, boolean>(opts?: (UseLoaderDataBaseOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, ... 8 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/posts/$slug", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useLoaderData();
// The route already waited for this, so this is the post itself.
const [post] = useQuery<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, undefined>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>, options?: (Options & {
readonly throwOnError?: never;
} & {
readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, const data: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
data.post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post); return (
<React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
<React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}>
<function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
Comments handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
handle={const data: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
data.comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense>
</React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
);
}
function function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}
props: { handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
handle: interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeof function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>
commentsQuery> }) {
// May still be loading. If so, use() suspends until it lands.
const [pagesPromise] = useQuery<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>, undefined>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>, handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>, options?: (Options & ... 1 more ... & {
...;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>
commentsQuery, props: {
handle: QueryHandle<typeof commentsQuery>;
}
props.handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
handle); const const pages: InfiniteData<{
page: CommentPage;
pageParam: number;
}>
pages = use<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>(usable: React.Usable<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>): InfiniteData<{
page: CommentPage;
pageParam: number;
}>
use(const pagesPromise: Thenable<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>
pagesPromise);
return const pages: InfiniteData<{
page: CommentPage;
pageParam: number;
}>
pages.InfiniteData<{ page: CommentPage; pageParam: number; }>.pages: readonly CommentPage[]pages.ReadonlyArray<CommentPage>.flatMap<PostComment, undefined>(callback: (this: undefined, value: CommentPage, index: number, array: CommentPage[]) => PostComment | readonly PostComment[], thisArg?: undefined): PostComment[]flatMap((page: CommentPagepage) => page: CommentPagepage.CommentPage.items: readonly PostComment[]items).Array<PostComment>.map<React.JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map(function renderComment(comment: PostComment): React.JSX.ElementrenderComment);
}See Starting queries for choosing between prefetch and ensure.
Handles in route context
beforeLoad can return handles too. They're part of the route context, so child routes' loaders and components read them, and the router serializes them to the browser with the rest of the context:
import { function createFileRoute<TFilePath extends keyof FileRoutesByPath, TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]["parentRoute"], TId extends RouteConstraints["TId"] = FileRoutesByPath[TFilePath]["id"], TPath extends RouteConstraints["TPath"] = FileRoutesByPath[TFilePath]["path"], TFullPath extends RouteConstraints["TFullPath"] = FileRoutesByPath[TFilePath]["fullPath"]>(path?: TFilePath): FileRoute<TFilePath, TParentRoute, TId, TPath, TFullPath>["createRoute"]createFileRoute, function redirect<TRouter extends AnyRouter = RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, const TTo extends string | undefined = ".", const TFrom extends string = string, const TMaskFrom extends string = TFrom, const TMaskTo extends string = "">(opts: RedirectOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>): Redirect<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>
redirect } from "@tanstack/react-router";
import { function meQuery(): QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>
meQuery } from "~/queries.ts";
export const const Route: Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "", "/", "/_auth", "/_auth", undefined, ResolveParams<"">, AnyContext, AnyContext, (args: BeforeLoadContextOptions<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 6 more ..., undefined>) => Promise<...>, ... 6 more ..., undefined>
Route = createFileRoute<"/_auth", RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/_auth", "", "/">(path?: "/_auth" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<"", TParams> & FilebaseRouteOptionsInterface<...> & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute("/_auth")({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 12 more ..., undefined>.beforeLoad?: Constrain<(args: BeforeLoadContextOptions<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 6 more ..., undefined>) => Promise<...>, (ctx: BeforeLoadContextOptions<...>) => Promise<...>> | undefined
beforeLoad: async (args: BeforeLoadContextOptions<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, undefined, ResolveParams<"">, AnyContext, AnyContext, "/_auth", unknown, undefined>
args) => {
const const qc: QueryClient<never>qc = args: BeforeLoadContextOptions<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, undefined, ResolveParams<"">, AnyContext, AnyContext, "/_auth", unknown, undefined>
args.BeforeLoadContextOptions<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 6 more ..., undefined>.context: {
queryClient: QueryClient;
}
context.queryClient: QueryClient<never>queryClient;
const const me: QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>
me = await const qc: QueryClient<never>qc.QueryClient<never>.ensure<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>(factory: () => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>, input?: undefined): Promise<QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>> (+1 overload)
ensure(function meQuery(): QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>
meQuery);
if (!const qc: QueryClient<never>qc.QueryClient<never>.getData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>(factory: () => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>, input?: undefined): User | undefined
getData(function meQuery(): QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>
meQuery)) {
throw redirect<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, "/login", string, string, "">(opts: RedirectOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, ... 8 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, "/login", string, "">): Redirect<...>
redirect({ RequiredToOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, "/login">.to: "/login" | "/posts" | "/settings" | "/todos" | "/blog/$slug" | "/posts/$slug" | "/" | "." | ".." | "/dashboard"to: "/login" });
}
return { me: QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>
me };
},
});function function Dashboard(): React.JSX.ElementDashboard() {
const const context: {
queryClient: QueryClient;
me: QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>;
}
context = const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "", "/", "/_auth", "/_auth", undefined, ResolveParams<"">, AnyContext, AnyContext, (args: BeforeLoadContextOptions<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 6 more ..., undefined>) => Promise<...>, ... 6 more ..., undefined>, ... 15 more ..., undefined>
Route.RouteExtensions<"/_auth/dashboard", "/dashboard">.useRouteContext: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, unknown>(opts?: UseRouteContextBaseOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, ... 8 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/_auth/dashboard", true, unknown> | undefined) => {
...;
}
useRouteContext();
const [const me: Userme] = useQuery<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>, undefined>(factory: () => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>, handle: QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>, options?: (Options & {
readonly throwOnError?: never;
} & {
readonly throwOnError?: undefined;
}) | undefined): readonly [data: User, controls: QueryControls<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>] (+1 overload)
useQuery(function meQuery(): QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>
meQuery, const context: {
queryClient: QueryClient;
me: QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>;
}
context.me: QueryHandleWithData<() => QueryOptions<Flat<{
data: User;
key: readonly ["me"];
}>>>
me);
return <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const me: Userme.User.name: stringname}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>;
}Importing @raf-query/react/tanstack-router registers handles as serializable, so Start's type check of beforeLoad's return accepts them.
Router settings
defaultPreload: "intent"runs a route's loader when the user hovers or focuses a link, so the queries start before the click.defaultPreloadStaleTime: 0makes the router run the loader again on click. Otherwise it reuses a hover's preload for 30 seconds, and the query cache never hears about the navigation. The query cache dedupes, so running the loader again is free when the data is fresh.
Pending UI
While a loader's ensure is still running, the previous page stays on screen. Show that a navigation is in progress with the router — not useIsFetching:
pendingComponentandpendingMsshow a full-page skeleton after the delay (1 second by default) for routes whose loaderensures slow data.useRouterState({ select: (state) => state.isLoading })drives a progress bar or spinner in the layout for any pending navigation:
function function NavigationProgress(): React.JSX.Element | nullNavigationProgress() {
const const isLoading: booleanisLoading = function useRouterState<boolean>(options: {
readonly select: (state: {
readonly isLoading: boolean;
}) => boolean;
}): boolean
useRouterState({ select: (state: {
readonly isLoading: boolean;
}) => boolean
select: (state: {
readonly isLoading: boolean;
}
state) => state: {
readonly isLoading: boolean;
}
state.isLoading: booleanisLoading });
return const isLoading: booleanisLoading ? <function ProgressBar(): ReactNodeProgressBar /> : null;
}Per-query background refetches after the page has rendered use controls.isFetching. See Background fetching indicators.
Search params
Use loaderDeps to pass validated search params to the loader, so it reruns when they change. See URL state and pagination.
Server and browser requests
A loader runs on the server during SSR and in the browser after, so a factory's queryFn runs in both.
Server functions
Factories can call server functions (createServerFn) directly, and get both sides for free. During SSR the call is a function call that sees the page request; in the browser, refetches call the same function over HTTP, with the browser's cookies.
export function function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery(input: {
slug: string;
}
input: { slug: stringslug: string }) {
return queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
data: Post;
error: unknown;
key: readonly ["post", string];
services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
queryOptions({
queryKey: readonly ["post", string]queryKey: ["post", input: {
slug: string;
}
input.slug: stringslug],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
data: Post;
error: unknown;
key: readonly ["post", string];
services: never;
}>
queryFn: () => function getPost(opts: RequiredFetcherDataOptions<undefined, ZodObject<{
slug: ZodString;
}, $strip>>): Promise<Post>
getPost({ RequiredFetcherDataOptions<undefined, ZodObject<{ slug: ZodString; }, $strip>>.data: {
slug: string;
}
data: input: {
slug: string;
}
input }),
});
}
Your own API
A factory that calls an API needs the user's cookies on both sides. The browser sends them; during SSR, forward the page request's:
import { function createIsomorphicFn(): IsomorphicFnBasecreateIsomorphicFn } from "@tanstack/react-start";
import { function getRequestHeader(name: RequestHeaderName): string | undefinedgetRequestHeader } from "@tanstack/react-start/server";
export const const forwardedHeaders: IsomorphicFn<[], Record<string, string>, Record<string, string>>forwardedHeaders = function createIsomorphicFn(): IsomorphicFnBasecreateIsomorphicFn()
.IsomorphicFnBase.server: <[], Record<string, string>>(serverImpl: () => Record<string, string>) => ServerOnlyFn<[], Record<string, string>>server((): type Record<K extends keyof any, T> = { [P in K]: T; }Record<string, string> => {
const const cookie: string | undefinedcookie = function getRequestHeader(name: RequestHeaderName): string | undefinedgetRequestHeader("cookie");
return const cookie: string | undefinedcookie === var undefinedundefined ? {} : { cookie: stringcookie };
})
.ServerOnlyFn<[], Record<string, string>>.client: <Record<string, string>>(clientImpl: () => Record<string, string>) => IsomorphicFn<[], Record<string, string>, Record<string, string>>client((): type Record<K extends keyof any, T> = { [P in K]: T; }Record<string, string> => ({}));- Send
forwardedHeaders()with each request: Hono'shcand tRPC's and oRPC's links take aheadersfunction. - Forward only what the API reads (
cookie,authorization), not every header. - For an API in the same app, call it in the process during SSR instead of over HTTP to your own origin.
examples/tanstack-startpicks an in-process client on the server and an HTTP one in the browser with abrowserexport condition, so the API's code stays out of the browser bundle.
Static prerendering
These docs are a TanStack Start app, prerendered to static HTML. Every page's loader ensures its content during the build, the content crosses in the page's HTML, and later navigations load it in the browser. See the source.
Example
examples/tanstack-start is a complete issue tracker: lists with filters and infinite scroll, details with comments, and mutations with optimistic updates.