UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

615 lines (464 loc) • 23 kB
# Queries > Reading from the server over GET, and letting TanStack Query or SWR own everything above it. `query()` marks a server function as a read, so the call goes out as a GET instead of a POST. Your logs and rate limiters can then tell it apart from a mutation, and a cache can hold the answer. ```ts title="src/listings.ts" "use server" import { query } from "@rsc-kit/core/query" export const getListings = query(async (kind: string) => { return db.listings.where({ kind }) }) ``` That is the whole feature. There is no cache here, no batching and no deduplication: [TanStack Query](https://tanstack.com/query) and [SWR](https://swr.vercel.app) already do those and do them better. **This owns the transport; they own everything above it.** You still need `"use server"`, and it is not claiming your function mutates. Despite the name it means *this may be called from the browser* — it is what gives the function an id and the client a stub to call it through. Two markers, two jobs. `"use server"` says the function crosses the boundary. `query()` says it is a read. :::caution[The call site picks the method] `query()` permits the GET; it does not force it. Calling the function directly from a client component is still a POST, because the browser cannot tell a query id from an action id — only `fetchQuery` sends a GET. ```tsx getListings(kind) // POST, even though it is a query fetchQuery(getListings, [kind]) // GET, and [kind] is typed from getListings ``` Development warns from the server when a query arrives at the action endpoint, so a forgotten `fetchQuery` shows up rather than quietly costing you the method. ::: ## What it changes Only the method. Same types, same arguments, same lack of an endpoint to write. Three things follow from that: - **Access logs and rate limiters** stop counting your reads as mutations. A limiter that allows 10 writes a minute should not be spending them on a list being refreshed. - **A GET is the only shape a cache can keep.** A browser cache, a CDN or a service worker can hold one; a POST can never be held by any of them. - **Repeating it is safe by contract**, which is what a prefetcher, a crawler or a retrying proxy assumes when it sees a GET. TanStack Start makes the same choice from the other side: its `createServerFn()` is a GET unless you ask for `{ method: 'POST' }`. We cannot default it that way — React gives us one directive for everything, so a GET default would send mutations as GET too. ### If you skip it A plain server action still works as a cache library's fetcher, and nothing breaks: ```tsx useQuery({ queryKey: ["listings", kind], queryFn: () => getListings(kind) }) ``` You lose the three things above and keep everything else. So this is a reasonable place to start, and `query()` is the thing to reach for once a read is worth being precise about — anything hot enough to show up in a rate limiter, or public enough to be worth caching. One caveat on the second: data that is the same for everyone usually wants [prerendering](/guides/static-generation/) rather than a client read at all, and a personal read answers `no-store` and is not cached either way. The caching win is real, and narrower than it sounds. ## Reading it **In a server component, call it.** It is ordinary server code — no HTTP, no cache, no client involved: ```tsx const listings = await getListings("stay") ``` **Better still, do not await it.** Pass the promise down and let a client component resolve it, and the data streams with the page: ```tsx export default function Page() { const listings = getListings("stay") return ( <Suspense fallback={<Skeleton />}> <List listings={listings} /> {/* "use client": use(listings) */} </Suspense> ) } ``` React serialises the promise as a pending row in the payload, so the shell paints at once and the rows arrive when the query answers — in the same response, with no request from the browser. Nothing in this package is involved; `use()` is React's. Reach for this first. **When the browser decides what to read** — a filter, another page, a refresh — call it. `fetchQuery` is an async function that goes to the server and gives back the typed answer; nothing else is required: ```tsx 'use client' import { useState, useTransition } from 'react' import { fetchQuery } from '@rsc-kit/core/queryClient' import { getListings } from '@/queries' export function Listings({ initial }: { initial: Listing[] }) { const [listings, setListings] = useState(initial) // the server-rendered page one const [pending, start] = useTransition() const show = (kind: string) => start(async () => setListings(await fetchQuery(getListings, [kind]))) return ( <> <button onClick={() => show('stay')} disabled={pending}>Stays</button> <button onClick={() => show('rent')} disabled={pending}>Rentals</button> <List listings={listings} /> </> ) } ``` That is the whole pattern: an event handler, `await fetchQuery(...)`, a `setState`. `useTransition` keeps the old list on screen while the new one loads and gives you `pending` for the button. The same call works in a `useEffect`, in an `onSubmit`, anywhere in the browser. **When you want caching**, hand the same call to the library that will hold the answer. `fetchQuery` goes to the server every time; staleness, revalidation and deduplication belong to the library, not to the fetcher: ```tsx // TanStack Query useQuery({ queryKey: ["listings", kind], queryFn: () => fetchQuery(getListings, [kind]), }) // SWR useSWR(["listings", kind], () => fetchQuery(getListings, [kind])) ``` :::caution[Keep the arrow] TanStack calls a bare `queryFn` with its own context — `{ client, queryKey, meta, signal }` — and a server function serialises whatever it is handed, so passing one unwrapped would try to put an `AbortSignal` on the wire. The arrow is where you choose what travels. ::: ### Server data in the cache The two above are the whole answer for most pages: `use()` when the server knows what to read, `fetchQuery` when the browser decides. This one is for data a cache library holds **and** that should already be on the page when it arrives: a list the browser refetches on focus, polls, pages through, or refreshes after a mutation. The server reads it, the page carries it, and the library starts with it rather than fetching it again. Data that is only displayed does not need it; a server component, or a promise passed to `use()`, is less code. Write the query's options once, and import them from both sides: ```ts title="src/listings.ts" import { queryOptions } from '@tanstack/react-query' import { fetchQuery } from '@rsc-kit/core/queryClient' import { getListings } from '@/queries' export const listingsQuery = (kind: string) => queryOptions({ queryKey: ['listings', kind], // On the server it is just a function; in the browser it goes out as a GET. queryFn: () => (typeof window === 'undefined' ? getListings(kind) : fetchQuery(getListings, [kind])), // Without it, TanStack treats the seeded data as stale and refetches on mount. staleTime: 60_000, }) ``` The server component starts the read and hands the cache down: ```tsx title="src/app/listings/page.tsx" import { Suspense } from 'react' import { QueryClient, dehydrate, HydrationBoundary, defaultShouldDehydrateQuery } from '@tanstack/react-query' import { listingsQuery } from '@/listings' import { Listings } from './listings' export default function Page() { // A new client per request, here and never at module scope: a module-level // one on the server is shared by every visitor. const qc = new QueryClient({ defaultOptions: { // Queries still in flight go along too, as promises the page streams. dehydrate: { shouldDehydrateQuery: (q) => defaultShouldDehydrateQuery(q) || q.state.status === 'pending' }, }, }) qc.prefetchQuery(listingsQuery('stay')) // not awaited return ( <HydrationBoundary state={dehydrate(qc)}> <Suspense fallback={<Skeleton />}> <Listings kind="stay" /> </Suspense> </HydrationBoundary> ) } ``` ```tsx title="src/app/listings/listings.tsx" 'use client' import { useSuspenseQuery } from '@tanstack/react-query' import { listingsQuery } from '@/listings' export function Listings({ kind }: { kind: string }) { const { data } = useSuspenseQuery(listingsQuery(kind)) return <List listings={data} /> } ``` `HydrationBoundary` needs a `QueryClientProvider` above it, like any TanStack hook. From there on it is TanStack: `invalidateQueries(['listings'])` after an action refetches through `fetchQuery`. SWR does the same with `fallback`, awaited rather than streamed: ```tsx title="src/app/listings/page.tsx" import { SWRConfig, unstable_serialize } from 'swr' import { getListings } from '@/queries' import { Listings } from './listings' export default async function Page() { const listings = await getListings('stay') return ( <SWRConfig value={{ fallback: { [unstable_serialize(['listings', 'stay'])]: listings } }}> <Listings kind="stay" /> </SWRConfig> ) } ``` ```tsx title="src/app/listings/listings.tsx" 'use client' import useSWR from 'swr' import { fetchQuery } from '@rsc-kit/core/queryClient' import { getListings } from '@/queries' export function Listings({ kind }: { kind: string }) { // The key must match the one in fallback. revalidateOnMount: false keeps // SWR from fetching again the moment the page loads. const { data } = useSWR(['listings', kind], () => fetchQuery(getListings, [kind]), { revalidateOnMount: false }) return <List listings={data ?? []} /> } ``` Either way, the page's first render makes no request for the data: measured, zero requests to the query endpoint on load, with the data on screen. #### What crosses from server to browser The options file is imported by both environments, and that is safe, with two things to hold to: - **Server code stays on the server.** `getListings` comes from a `"use server"` module, so the browser bundle gets a reference to call it by, not its body; the `typeof window` branch that calls it directly never runs there. Checked on a build: nothing the query reads appeared in any browser chunk. What would leak is importing server-only code into the options file itself (a database client, `src/env.ts`), which is what the `"use server"` module is for. - **The data does reach the browser.** A dehydrated cache or a `fallback` is serialized into the page, the same as a prop. Seed only what the visitor may see, and create the `QueryClient` inside the component, per request, as above: a module-level one on the server carries one visitor's data into the next visitor's page. ### Paging, polling, and the rest All of it belongs to the library, and all of it works: ```tsx // Infinite. The cursor is just an argument. useInfiniteQuery({ queryKey: ["feed"], queryFn: ({ pageParam }) => fetchQuery(getFeed, [pageParam]), initialPageParam: null, getNextPageParam: (last) => last.nextCursor, initialData: { pages: [first], pageParams: [null] }, staleTime: 60_000, }) // Data that changes while you watch. No live connection needed. useQuery({ queryKey: ["seats"], queryFn: () => fetchQuery(getSeats), refetchInterval: 2_000 }) ``` Page one comes from a server component and costs no request; later pages are ordinary reads. `/infinite`, `/pagination` and `/polling` in the example app are these three patterns end to end. :::caution[A seed is stale by default] TanStack treats `initialData` as stale at its default `staleTime` of `0`, so without an explicit `staleTime` it refetches page one on mount and the round trip you seeded to avoid happens anyway. SWR's `fallbackData` has the same shape of caveat, and `refetchInterval` pauses while the tab is hidden. ::: ### Live data A value that keeps changing while someone watches is not what `query()` is — a query answers once and is cacheable — but a query is the natural thing to read *again*: ```tsx import { usePolling } from '@rsc-kit/core/usePolling' const { data: seats } = usePolling(() => fetchQuery(getSeats), { every: 2_000 }) ``` It reuses what you already wrote, needs no special route, and goes through the query's `Cache-Control` — a thousand tabs polling the same seat count become one origin request per interval at the CDN. It pauses while the tab is hidden and never overlaps two reads. **Until it settles.** A job that is queued, then running, then done wants polling that stops on its own. `until` says when a read is the last one, and `onSettled` fires once, on that read. The result is the data, and what to do when it settles is the page's to decide — so the same primitive serves two pages that want different things: ```tsx // A list of jobs, server-rendered, some still running: poll those until they // settle, then re-render the card through the server path that built it. usePolling(() => fetchQuery(jobStatus, [id]), { every: 2_000, enabled: !isTerminal(job), until: isTerminal, onSettled: () => refresh('page'), }) // The page that started the job and owns its state machine: the value in // hand, and the terminal state driving the next step. const { data, status } = usePolling(() => fetchQuery(jobStatus, [id]), { every: 1_500, until: isTerminal, onSettled: (final) => dispatch({ type: final.status }), }) ``` Settled means stopped: nothing is read again until `refresh()`, which starts it over, or the inputs change. A page that has to survive a reload keeps the job's id where it likes and passes it back in; the hook starts polling the moment it mounts with one. **No library needed.** `data` is state; a component that only wants to show the value reads it. When the value already lives somewhere, `onData` hands every answer to it — a `useState`, a reducer, or a cache library — so that stays the source of truth: ```tsx const [seats, setSeats] = useState(initial) usePolling(() => fetchQuery(getSeats), { every: 2_000, onData: setSeats }) // useState usePolling(() => fetchQuery(getSeats), { every: 2_000, onData: (s) => queryClient.setQueryData(['seats'], s), // TanStack }) ``` Neither hook needs a cache library; TanStack and SWR are shown because a page that already uses one should keep it as the one place the value lives. A read that fails does not stop the polling — the next interval reads again — and it reaches you two ways: `error` is the state, and `onError` fires per failed read, for a toast or a log, so a page that keeps its own state does not have to watch `error` in an effect: ```tsx usePolling(() => fetchQuery(getSeats), { every: 2_000, onData: setSeats, onError: (e, { failures }) => { if (failures === 3) toast.error('Could not refresh seats') }, }) ``` `failures` counts the failed reads in a row and a success resets it, so the third failure can be a toast where the first was a blip, without the page keeping a counter of its own. **When something can push**, the server sends events instead and the browser holds one connection per tab. That is a route, not a query: [a route that streams](/guides/api-routes/#a-route-that-streams). The trade-off is stated there. ### Where it will not work `fetchQuery` only works in the browser. React refuses a server-function call during the first render, so a component that also renders on the server must not call it there. You will not hit this through TanStack or SWR — they fetch in an effect, after hydration. ## Sharing a check across reads A query has no middleware above it, so each one checks for itself. If that means writing the same check in every file, put it on the client you already use for actions: ```ts title="src/server/client.ts" 'use server' import { createActionClient } from '@rsc-kit/core/action' export const client = createActionClient({ onError: report }) .use(async ({ next }) => { const user = await currentUser() if (!user) throw new Error('Not signed in') return next({ ctx: { user } }) }) ``` ```ts title="src/server/posts.ts" 'use server' import { client } from './client' export const createPost = client.input(schema).handler(async ({ input, ctx }) => …) // POST export const getPosts = client.input(filter).query(async ({ input, ctx }) => …) // GET ``` One client, one set of middleware, one `onError`. `.handler()` makes an action; `.query()` makes a query. Neither can be added without the check. ### Called with the schema's input A query built from `createActionClient().input(schema)` takes that schema's input, and nothing else: `getLogs({ team: 1, nonsense: true })` and `getLogs()` both fail the typecheck, not the read. One without a schema takes no argument. ### A query cannot redirect A query is read by a cache library, not navigated to, so a `redirect()` in its middleware has nowhere to go and the read fails. Refuse instead — throw `ServerAuthenticationError` (a 401) or `ServerAuthorizationError` (a 403) — and let the cache's error handler send the visitor to sign in. The same refusal in an action or a page does redirect. ### They fail differently, on purpose An action **returns** its failures, because React strips a thrown message in production. A query **throws** — every cache library reports failure by rejection, and one that answered with an error-shaped object would look like a successful read of something odd. So a refused read rejects with a real `Error`. A validation failure keeps its fields: ```tsx const { error } = useQuery({ queryKey: ['posts'], queryFn: () => fetchQuery(getPosts, [filter]) }) error.message // 'Validation failed' error.errors // { title: ['too short'] } ``` ## Securing a query A query is a public GET endpoint. Treat it as one. ### Route middleware does not run `middleware.ts` guards a **route**, and a query has no route — it is reachable whoever is asking and whatever page they came from. A query behind a guarded page is not guarded. What you do get is the request: the visitor's cookies and session are bound for the call, so a query can check for itself. One query is fine written by hand: ```ts export const getOrders = query(async () => { const user = await currentUser() if (!user) throw new Error("Not signed in") return db.orders.forUser(user.id) }) ``` Twenty is twenty chances to forget, and the one you forget is the one that matters. Put the check on the client you already use for actions and build every read from it: ```ts title="src/server/orders.ts" 'use server' import { client } from './client' export const getOrders = client.query(async ({ ctx }) => db.orders.forUser(ctx.user.id)) ``` `ctx.user` is typed and non-null because the only way into the handler was through the middleware that put it there — so a query **cannot be added without the check**. Same client, same middleware, same `onError` as your actions; `.handler()` makes a mutation and `.query()` makes a read. See [sharing a check across reads](#sharing-a-check-across-reads) below. Authorise on **identity, not arguments**. `getOrder(id)` that trusts the id is the whole of an IDOR: the caller chooses the id. ### Only `query()` is reachable The endpoint refuses anything that is not a query, which is what keeps every action you have registered off a GET url. So export the wrapped value, not the bare function beside it. An unknown id and a real-but-unmarked one get the same 404. Telling them apart would let someone probe for your action ids. ### A query must never write It is a GET. A prefetcher, a crawler or a retry will repeat it. ### What the endpoint does for you - **Requires `X-RSC-Query`.** A GET with no unusual header is a *simple* request, so any page anywhere could trigger one with `<img src="…/_rsc/query?…">` and it would carry the visitor's cookies — CORS stops them reading the answer, not the read running. That header is not CORS-safelisted, so a browser preflights it and nothing here answers a preflight. It is the same protection a POST carrying `X-RSC-Action` had. - Refuses a cross-origin request when an `Origin` is present. - Refuses an oversized url with `414` before decoding anything. - Sends `Vary: Cookie`, so a cacheable answer is never shared between visitors. None of that authorises anything. The query does that. ### Arguments are public They travel in the url, so they reach access logs, browser history and referrer headers. Never take a token or a password reset code as a query argument — that is an action. Arguments too large for a url, or containing a `File`, make the read fall back to a POST rather than failing. It still works; it simply stops being cacheable, and development warns when it happens. ## Caching There is no cache in `fetchQuery`, and there will not be one: a client cache is where "small and ours" goes wrong — a `Map` first, then staleness, then invalidation, then dedupe across components, and it ends as a worse TanStack Query that every guide has to teach. What this package owns is the part a library cannot: whether the answer is cacheable *at all*, which is an HTTP question. So the choice is a ladder, and most reads stop on the first rung: | you need | use | | --- | --- | | the value, now | `fetchQuery` in a handler and `setState` — every call goes to the server | | the answer to survive a reload, or a CDN to serve it | `cache` on the query, below — the browser and the CDN hold it, with no code in the page | | staleness, background refresh, optimistic updates, one value shared across components | TanStack Query or SWR, with `fetchQuery` as the fetcher | Answers default to `private, no-store`. A query may read the session, and a cacheable answer to a personal read is how one visitor is served another's data. ```ts export const getPricing = query(async () => tiers(), { cache: "public", maxAge: 300 }) ``` ### You probably do not need to change it There are two caches and they solve different problems. **Your cache library's**, in memory and per tab, decides whether to ask again. It needs nothing from this option — `no-store` does not stop TanStack or SWR holding an answer, because they are not an HTTP cache. **The HTTP one**, which `cache` controls, decides whether an answer survives a reload, works offline, or can be shared by a CDN. Most apps want only the first. And note that data which is the same for everyone usually wants [prerendering](/guides/static-generation/) rather than a client read at all — which is a better answer than any cache. For a personal read you want across reloads, persist your cache library rather than widening the read. ## During a build A query is an ordinary function, so it follows the ordinary rule. One reading a database the build machine can reach runs at build time and the page is frozen with real data in it. Call `connection()` inside the query to opt out.