Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

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

src/router.tsx
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:

src/routes/__root.tsx
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 a queryFn into a navigation, wherever the query failed: a loader, a render or a background refetch in the browser.

Options:

OptionUse
dehydrateOptionsfilter which entries are sent, keepErrors, serializeData
hydrateOptionsdeserializeData, the counterpart of serializeData
handleRedirectsfalse to handle redirects yourself

Loaders

A loader starts every query its route needs and returns the handles:

src/routes/posts.$slug.tsx
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
);
const post: 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 pagesPromise: Thenable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>
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:

src/routes/_auth.tsx
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
};
}, });
src/routes/_auth.dashboard.tsx
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: 0 makes 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:

  • pendingComponent and pendingMs show a full-page skeleton after the delay (1 second by default) for routes whose loader ensures 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.

src/queries.ts
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:

src/forwarded-headers.ts
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's hc and tRPC's and oRPC's links take a headers function.
  • 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-start picks an in-process client on the server and an HTTP one in the browser with a browser export 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.