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():
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:
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:
import { function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary } from "@raf-query/react";
import { const Suspense: ExoticComponent<SuspenseProps>Suspense } from "react";
import { function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): JSX.Element[]
Comments } from "../../components/comments.tsx";
import { function CommentsSkeleton(): JSX.ElementCommentsSkeleton } from "../../components/comments-skeleton.tsx";
import { function PostSkeleton(): JSX.ElementPostSkeleton } from "../../components/post-skeleton.tsx";
import { function Post(props: {
handle: QueryHandle<typeof postQuery>;
}): JSX.Element
Post } from "../../components/post.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";
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>
);
}"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 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>;
}"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); 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
HydrationBoundaryin each server component that starts queries for client components. Entries already sent in this request are skipped, so nesting them is fine. clientworks only in server components. The bundler resolves@raf-query/reactwith thereact-serverexport condition there, which picks theHydrationBoundarythat dehydrates the client. Client components get the one that takes a dehydratedstate, as in React Router.- Pending entries stream as promises through the RSC payload: the page's shell renders while
commentsloads. await qc.fetch(...)returns a handle with thedata: renderpost.datain the server component, and passpostto client components that read the query: typedQueryHandleWithData, they get the data fromuseQuerywithout a promise. The data crosses once: the RSC payload references theHydrationBoundary's copy.- Use
prefetchandfetchin server components, notensure. Each request's client starts empty, soensurewaits for the request every time, exactly likefetch. 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:
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 components | Browser | |
|---|---|---|
| When | The page request | Refetches and later pages |
fetch("/api/...") | Fails: there is no page origin to resolve it | Works |
| The user's cookies | Yours 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:
{
"imports": {
"#api-fetch": {
"react-server": "./src/api-fetch.server.ts",
"default": "./src/api-fetch.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 }));
}// 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);
}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/nextjsgives a Hono client the page request's cookies insrc/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, anAuthorizationheader. - 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:
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.