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

React Server Components (Next.js)

Start queries in server components, send them to client components with HydrationBoundary, and refetch from the browser. For the Next.js App Router and other RSC frameworks.

With React Server Components, server components play the part of loaders: they start queries and pass handles to client components. QueryProvider and HydrationBoundary, both from @raf-query/react, connect them.

The samples use the Next.js App Router. Other RSC frameworks work the same way: the integration needs only a bundler that resolves the react-server export condition, which React itself needs too. It's tested on Vite's RSC plugin, which Waku and React Router's RSC mode build on. Navigation, request headers and server actions are the framework's own, so those sections show the Next.js APIs.

Setup

A query client per request for server components, scoped by React's cache():

src/query-client.ts
import { class QueryClient<TServices = never>QueryClient, type interface QueryClientConfig<TServices = never>QueryClientConfig } from "@raf-query/react";
import { function cache<CachedFunction extends Function>(fn: CachedFunction): CachedFunctioncache } from "react";

export const 
const queryClientConfig: {
    readonly defaults: {
        readonly staleTime: 5000;
    };
}
queryClientConfig
= {
Config.defaults?: QueryBehavior | undefineddefaults: { QueryBehavior.staleTime?: number | undefinedstaleTime: 5000 }, } as
type const = {
    readonly defaults: {
        readonly staleTime: 5000;
    };
}
const
satisfies interface QueryClientConfig<TServices = never>QueryClientConfig;
export const const getQueryClient: () => QueryClient<never>getQueryClient = cache<() => QueryClient<never>>(fn: () => QueryClient<never>): () => QueryClient<never>cache(() => new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient(
const queryClientConfig: {
    readonly defaults: {
        readonly staleTime: 5000;
    };
}
queryClientConfig
));

QueryProvider in the root layout, for client components:

src/app/layout.tsx
import { 
function QueryProvider(props: {
    readonly children?: ReactNode;
    readonly config?: QueryClientConfig;
}): JSX.Element
QueryProvider
} from "@raf-query/react";
import { function QueryDevtools(props: QueryDevtoolsProps): JSX.Element | nullQueryDevtools } from "@raf-query/react/devtools"; import type { type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefinedReactNode } from "react"; import {
const queryClientConfig: {
    readonly defaults: {
        readonly staleTime: 5000;
    };
}
queryClientConfig
} from "~/query-client.ts";
export default function
function RootLayout(props: {
    children: ReactNode;
}): JSX.Element
RootLayout
(
props: {
    children: ReactNode;
}
props
: { children: ReactNodechildren: type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefinedReactNode }) {
return ( <JSX.IntrinsicElements.html: DetailedHTMLProps<HtmlHTMLAttributes<HTMLHtmlElement>, HTMLHtmlElement>html HTMLAttributes<HTMLHtmlElement>.lang?: string | undefinedlang="en"> <JSX.IntrinsicElements.body: DetailedHTMLProps<HTMLAttributes<HTMLBodyElement>, HTMLBodyElement>body> <
function QueryProvider(props: {
    readonly children?: ReactNode;
    readonly config?: QueryClientConfig;
}): JSX.Element
QueryProvider
config?: QueryClientConfig<never> | undefinedconfig={
const queryClientConfig: {
    readonly defaults: {
        readonly staleTime: 5000;
    };
}
queryClientConfig
}>
{
props: {
    children: ReactNode;
}
props
.children: ReactNodechildren}
{var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvenv.string | undefinedNODE_ENV !== "production" ? <function QueryDevtools(props: QueryDevtoolsProps): JSX.Element | nullQueryDevtools /> : null} </
function QueryProvider(props: {
    readonly children?: ReactNode;
    readonly config?: QueryClientConfig;
}): JSX.Element
QueryProvider
>
</JSX.IntrinsicElements.body: DetailedHTMLProps<HTMLAttributes<HTMLBodyElement>, HTMLBodyElement>body> </JSX.IntrinsicElements.html: DetailedHTMLProps<HtmlHTMLAttributes<HTMLHtmlElement>, HTMLHtmlElement>html> ); }

QueryProvider creates one client per server render and one per browser tab. Its config must be serializable, since it crosses from the server component layout to the client.

Mount DevTools only when process.env.NODE_ENV !== "production" so the bundler drops the panel from production builds. The example app always renders it, so the panel stays available in the deployed demo.

Pages

A server component starts queries, and wraps what it renders in a HydrationBoundary that sends the request client's entries to the browser:

src/app/(blog)/posts/[slug]/page.tsx
import { function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary } from "@raf-query/react";
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";
import { const getQueryClient: () => QueryClient<never>getQueryClient } from "~/query-client.ts"; export default async function
function Page(props: {
    params: Promise<{
        slug: string;
    }>;
}): Promise<JSX.Element>
Page
(
props: {
    params: Promise<{
        slug: string;
    }>;
}
props
: {
params: Promise<{
    slug: string;
}>
params
: interface Promise<T>Promise<{ slug: stringslug: string }>;
}) { const { const slug: stringslug } = await
props: {
    params: Promise<{
        slug: string;
    }>;
}
props
.
params: Promise<{
    slug: string;
}>
params
;
const const qc: QueryClient<never>qc = function getQueryClient(): QueryClient<never>getQueryClient(); // Never waits: both load while the page streams. 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 });
const
const post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(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;
}): QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
prefetch
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug });
return ( <function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary client: QueryClient<never>client={const qc: QueryClient<never>qc}> <JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: ExoticComponent<SuspenseProps>Suspense SuspenseProps.fallback?: ReactNodefallback={<function PostSkeleton(): JSX.ElementPostSkeleton />}> <
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): JSX.Element
Post
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
={
const post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
} />
</const Suspense: ExoticComponent<SuspenseProps>Suspense> <const Suspense: ExoticComponent<SuspenseProps>Suspense SuspenseProps.fallback?: ReactNodefallback={<function CommentsSkeleton(): JSX.ElementCommentsSkeleton />}> <
function Comments(props: {
    handle: QueryHandle<typeof commentsQuery>;
}): JSX.Element[]
Comments
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
={
const comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
} />
</const Suspense: ExoticComponent<SuspenseProps>Suspense> </JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> </function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary> ); }
src/app/(blog)/components/post.tsx
"use client";
export function 
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): JSX.Element
Post
(
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
: {
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
: interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
> }) {
const [postPromise] =
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: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
.
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
);
const postPromise: Thenable<Post>
const const post: Postpost = use<Post>(usable: Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }
src/app/(blog)/components/comments.tsx
"use client";
export function 
function Comments(props: {
    handle: QueryHandle<typeof commentsQuery>;
}): 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 [
const pagesPromise: Thenable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>
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 pages =
use<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>(usable: Usable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>): InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>
use
(
const pagesPromise: Thenable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>
pagesPromise
);
const pages: InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>
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<JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => JSX.Element, thisArg?: any): JSX.Element[]map((comment: PostCommentcomment) => <
function Comment(props: {
    comment: PostComment;
}): React.ReactNode
Comment
Attributes.key?: Key | null | undefinedkey={comment: PostCommentcomment.PostComment.id: stringid} comment: PostCommentcomment={comment: PostCommentcomment} />);
}
  • Render a HydrationBoundary in each server component that starts queries for client components. Entries already sent in this request are skipped, so nesting them is fine.
  • client works only in server components. The bundler resolves @raf-query/react with the react-server export condition there, which picks the HydrationBoundary that dehydrates the client. Client components get the one that takes a dehydrated state, as in React Router.
  • Pending entries stream as promises through the RSC payload: the page's shell renders while comments loads.
  • await qc.fetch(...) returns a handle with the data: render post.data in the server component, and pass post to client components that read the query: typed QueryHandleWithData, they get the data from useQuery without a promise. The data crosses once: the RSC payload references the HydrationBoundary's copy.
  • Use prefetch and fetch in server components, not ensure. Each request's client starts empty, so ensure waits for the request every time, exactly like fetch. See Starting queries.

Pending UI

While a link navigation is loading the next RSC payload, show pending state with the framework's router — not useIsFetching. In Next.js, render useLinkStatus as a child of the Link it tracks:

"use client";

function function PendingHint(): JSX.Element | nullPendingHint() {
	const { const pending: booleanpending } = 
function useLinkStatus(): {
    readonly pending: boolean;
}
useLinkStatus
();
return const pending: booleanpending ? <function Spinner(): ReactNodeSpinner /> : null; } <
function Link(props: {
    readonly children?: ReactNode;
    readonly href: string;
}): ReactNode
Link
href: stringhref="/issues">
Issues <function PendingHint(): JSX.Element | nullPendingHint /> </
function Link(props: {
    readonly children?: ReactNode;
    readonly href: string;
}): ReactNode
Link
>;

Per-query background refetches after the page has rendered use controls.isFetching. See Background fetching indicators.

Search params

Read searchParams in the page, validate them, and pass them as the input:

src/app/issues/page.tsx
export default async function 
function IssuesPage(props: {
    searchParams: Promise<Record<string, string | Array<string>>>;
}): Promise<null>
IssuesPage
(
props: {
    searchParams: Promise<Record<string, string | Array<string>>>;
}
props
: {
searchParams: Promise<Record<string, string | string[]>>searchParams: interface Promise<T>Promise<type Record<K extends keyof any, T> = { [P in K]: T; }Record<string, string | interface Array<T>Array<string>>>; }) { const const input: IssueListFilterinput =
const IssueListInput: {
    parse(value: Record<string, string | Array<string>>): IssueListFilter;
}
IssueListInput
.function parse(value: Record<string, string | Array<string>>): IssueListFilterparse(await
props: {
    searchParams: Promise<Record<string, string | Array<string>>>;
}
props
.searchParams: Promise<Record<string, string | string[]>>searchParams);
const const qc: QueryClient<never>qc = function getQueryClient(): QueryClient<never>getQueryClient(); const
const issues: QueryHandle<(input: {
    status: "open" | "done";
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["issues", {
        status: "open" | "done";
    }];
    page: {
        items: Issue[];
        nextCursor: null;
    };
    pageParam: number;
}>>>
issues
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    status: "open" | "done";
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["issues", {
        status: "open" | "done";
    }];
    page: {
        items: Issue[];
        nextCursor: null;
    };
    pageParam: number;
}>>>(factory: (input: {
    status: "open" | "done";
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["issues", {
        status: "open" | "done";
    }];
    page: {
        items: Issue[];
        nextCursor: null;
    };
    pageParam: number;
}>>, input: {
    status: "open" | "done";
}): QueryHandle<(input: {
    status: "open" | "done";
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["issues", {
        status: "open" | "done";
    }];
    page: {
        items: Issue[];
        nextCursor: null;
    };
    pageParam: number;
}>>>
prefetch
(
function issueListQuery(input: {
    status: "open" | "done";
}): InfiniteQueryOptions<Flat<{
    key: readonly ["issues", {
        status: "open" | "done";
    }];
    page: {
        items: Issue[];
        nextCursor: null;
    };
    pageParam: number;
}>>
issueListQuery
, const input: IssueListFilterinput);
// ...

Changing search params with <Link> or router.push renders the page again on the server with the new input. See URL state and pagination for what to show while it loads.

Queries that start in the browser

QueryProvider's server render starts no requests. A client component reading a query that no HydrationBoundary sent renders its Suspense fallback on the server, and the query loads in the browser. This is what happens with useWaterfall in a client component. That boundary renders in the browser through React's error recovery, with the digest Next.js uses for ssr: false, so Next.js doesn't report it. Other RSC frameworks see a recoverable error.

Server and browser requests

A factory's queryFn runs in two places, and has to reach the same data as the same user in both:

Server componentsBrowser
WhenThe page requestRefetches and later pages
fetch("/api/...")Fails: there is no page origin to resolve itWorks
The user's cookiesYours to forward, from headers()Sent by the browser

Client components' server render starts no request, so it isn't a third place.

An API in the same app

Have the API read the session from the request's cookies, and give factories one apiFetch with two files behind it: on the server it calls the API's handler in the process with the page request's cookies, and in the browser it's fetch. A subpath import picks the file. The bundler resolves the react-server condition in server components, so the server file can import the request's headers (next/headers in Next.js) and your API, and neither reaches the browser bundle:

package.json
{
	"imports": {
		"#api-fetch": {
			"react-server": "./src/api-fetch.server.ts",
			"default": "./src/api-fetch.ts"
		}
	}
}
src/api-fetch.server.ts
import { function headers(): Promise<ReadonlyHeaders>headers } from "next/headers";

// The function `app/api/[...path]/route.ts` exports as `GET` and `POST`.
import { function handler(_request: Request): Promise<Response>handler } from "./api-handler.ts";

export async function function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch(path: stringpath: string, init: RequestInit | undefinedinit?: RequestInit) {
	const const forwarded: Headersforwarded = new var Headers: new (init?: HeadersInit) => HeadersHeaders(init: RequestInit | undefinedinit?.RequestInit.headers?: HeadersInit | undefinedheaders);
	const const cookie: string | nullcookie = (await function headers(): Promise<ReadonlyHeaders>headers()).Headers.get(name: string): string | nullget("cookie");
	if (const cookie: string | nullcookie !== null) {
		const forwarded: Headersforwarded.Headers.set(name: string, value: string): voidset("cookie", const cookie: stringcookie);
	}
	// The host is a placeholder: the handler routes by path.
	const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URLURL(path: stringpath, "http://localhost");
	return function handler(_request: Request): Promise<Response>handler(new var Request: new (input: RequestInfo | URL, init?: RequestInit) => RequestRequest(const url: URLurl, { ...init: RequestInit | undefinedinit, RequestInit.headers?: HeadersInit | undefinedheaders: const forwarded: Headersforwarded }));
}
src/api-fetch.ts
// The browser sends the page's cookies to its own origin.
export function function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch(path: stringpath: string, init: RequestInit | undefinedinit?: RequestInit) {
	return function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)fetch(path: stringpath, init: RequestInit | undefinedinit);
}
src/queries.ts
import { function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch } from "#api-fetch";

export function 
function issueQuery(input: {
    id: number;
}): QueryOptions<Flat<{
    data: any;
    key: readonly ["issue", number];
}>>
issueQuery
(
input: {
    id: number;
}
input
: { id: numberid: number }) {
return
queryOptions<any, unknown, never, readonly ["issue", number], undefined>(options: Omit<QueryOptions<{
    data: any;
    error: unknown;
    key: readonly ["issue", number];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: any;
    key: readonly ["issue", number];
}>>
queryOptions
({
queryKey: readonly ["issue", number]queryKey: ["issue",
input: {
    id: number;
}
input
.id: numberid],
function queryFn(context: QueryFunctionContext): anyqueryFn: (context: QueryFunctionContextcontext) => function apiFetch(path: string, init?: RequestInit): Promise<Response>apiFetch(`/api/issues/${
input: {
    id: number;
}
input
.id: numberid}`, { RequestInit.signal?: AbortSignal | null | undefinedsignal: context: QueryFunctionContextcontext.QueryFunctionContext.signal: AbortSignalsignal }).Promise<Response>.then<any, never>(onfulfilled?: ((value: Response) => any) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<any>then(
(response: Responseresponse) => response: Responseresponse.Body.json(): Promise<any>json(), ), }); }
  • The API sees the same request from both sides, so it authenticates the same way. Forward only what it reads (cookie, authorization), not every header.
  • Nothing needs the app's own origin, and server rendering doesn't open a second connection to itself.
  • A typed client works the same way. examples/nextjs gives a Hono client the page request's cookies in src/forwarded-headers.server.ts.
  • Reading headers() renders the page per request, as a page with per-user data must.

An API on another origin

The server can call the same URL as the browser, but the browser's cookies belong to your origin, not the API's:

  • In server components, forward the credentials it needs: read your session with cookies() and send, for example, an Authorization header.
  • In the browser, either proxy it through a Route Handler, so the request carries your cookies and the handler adds the same credentials, or send credentials: "include" to an API that allows your origin with CORS and sets its own cookies.

Pick the two implementations with the same react-server condition.

Server actions

Factories can call server actions, and get both sides for free: server components call the function in the page request, and the browser posts to it with its cookies. Validate their arguments, since anyone can call them.

The browser runs a page's server actions one at a time, so refetches wait for each other and for any pending mutation. For parallel requests, call your API instead.

Mutations

Write with a server action, then invalidate in the browser:

src/components/update-issue-form.tsx
return (
	<JSX.IntrinsicElements.form: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form
		FormHTMLAttributes<HTMLFormElement>.action?: string | ((formData: FormData) => void | Promise<void>) | undefinedaction={async (formData: FormDataformData) => {
			await function updateIssue(_formData: FormData): Promise<void>updateIssue(formData: FormDataformData);
			await const qc: QueryClient<unknown>qc.
QueryClient<unknown>.invalidate<(input: {
    id: number;
}) => QueryOptions<Flat<{
    data: any;
    key: readonly ["issue", number];
}>>>(...target: Target<(input: {
    id: number;
}) => QueryOptions<Flat<{
    data: any;
    key: readonly ["issue", number];
}>>>): Promise<void>
invalidate
(
function issueQuery(input: {
    id: number;
}): QueryOptions<Flat<{
    data: any;
    key: readonly ["issue", number];
}>>
issueQuery
, { id: numberid:
props: {
    id: number;
}
props
.id: numberid });
}} /> );

If the action also calls revalidatePath or refresh(), server components render again and send their entries, and the browser keeps whichever copy is newest.

Server-only queries

A factory that imports server-only code (a database client) can't be imported by a client component, so it can't be read with useQuery. Render its data in a server component, or pass a plain promise prop and use() it on the client. Refresh it with router.refresh().

To read it from client components too, put the server-only code behind an API, and reach that from the factory as in An API in the same app.

Example

examples/nextjs is the same issue tracker as the TanStack Start example, built on the Next.js App Router, so the two integrations can be compared side by side.