Next queries
Fetch data in a Next.js App Router app the canonical way — server-component prefetch + TanStack Query hydration + client useQuery on the SAME key. Use when the user adds a data read, a list/table page, a server action that fetches, or a useQuery hook; says "fetch X", "load data for", "prefetch", "hydrate", "why does my query refetch on first paint", "table blanks when I search"; or hits a hydration mismatch or a double round-trip. Pairs with `next-mutations` for writes + cache invalidation.From its SKILL.md
npx -y skills add sanctuarynode/skills --skill next-queriesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
7.4 KB, ~1.7k tokens by cl100k_base, as published. Nobody here has run it
Next.js + TanStack Query: fetching
Data flows server → client through one three-layer pattern: server component prefetch → TanStack Query hydration → client useQuery on the identical key.
page.tsx (dynamic server component)
resolve scope (session/params) → e.g. orgSlug
└─▶ queryClient.prefetchQuery queryKey: [scope, "resource"]
└─▶ server action ('use cache' + cacheTag + cacheLife)
└─▶ typed API client (Eden) → backend
└─▶ dehydrate(queryClient) into <HydrationBoundary>
│ serialized cache
▼
client component
└─▶ useQuery SAME queryKey + queryFn
├─ cache hydrated for this key? ─ yes ─▶ paint immediately, no refetch
└─ no / stale ─▶ refetch via the same server action
(always handle isPending → <Skeleton>)
Read it as: the server prefetches under a queryKey and hands the serialized cache to <HydrationBoundary>; the client useQuery uses the identical key + queryFn, finds the prefetched entry, and paints without a second round-trip. Scope (org/workspace/user) is resolved once on the server and passed down — server actions never read cookies/headers/session themselves.
The
orgSlug/scopesegment below is for multi-tenant apps. If your app isn't scoped, drop it from the cache tag and query key.
Rules
page.tsxis a dynamic server component — no'use cache', noexport const dynamic. It reads the session/params and passes scope as an argument.- Fetching server actions use
'use cache'+cacheTag+cacheLife; they receive everything as arguments and never read cookies/headers/session internally. - Client components read through
useQuerycalling the same server action — never call the API client directly from the client. - Every
useQuerymust handleisPendingand render a skeleton. - Server actions never leak raw errors — catch,
log.error(...), return a human-readable{ error }string.
Step 1 — fetching server action
// actions/things.ts
"use server";
import { cacheLife, cacheTag } from "next/cache";
import { log } from "@/lib/log";
import { api } from "@/lib/api"; // your typed client
export async function getThings(orgSlug: string) {
"use cache";
cacheTag(`${orgSlug}:things`); // tag format: "{scope}:{resource}"
cacheLife("hours"); // presets: minutes | hours | days | weeks | max
const { data, error } = await api.things.get(); // GET /things
if (error) {
log.error({ action: "getThings", scope: orgSlug, error });
return { error: "Failed to load things" };
}
return { data };
}
Step 2 — prefetch in page.tsx
import { headers } from "next/headers";
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
import { auth } from "@/lib/auth";
import { getQueryClient } from "@/lib/query";
import { getThings } from "@/actions/things";
import { ThingTable } from "./_components/thing-table";
export default async function Page() {
const { session } = await auth.api.getSession({ headers: await headers() });
const orgSlug = session?.activeOrganizationId ?? "";
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: [orgSlug, "things"],
queryFn: () => getThings(orgSlug),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ThingTable orgSlug={orgSlug} />
</HydrationBoundary>
);
}
Step 3 — consume in the client component
"use client";
import { useQuery } from "@tanstack/react-query";
import { getThings } from "@/actions/things";
export function ThingTable({ orgSlug }: { orgSlug: string }) {
const { data, error, isPending } = useQuery({
queryKey: [orgSlug, "things"], // identical key → reuses the prefetched cache
queryFn: () => getThings(orgSlug),
});
if (error) throw error;
if (isPending) return <ThingTableSkeleton />;
return <DataTable data={data} />;
}
Typed API client (Eden) call syntax
Import the typed client (api) from @/lib/api — the exact module shape is project-specific.
import { api } from "@/lib/api";
const { data, error } = await api.things.get();
const { data, error } = await api.things.post(body);
// dynamic path params — use FUNCTION CALL syntax, never bracket notation
const { data } = await api.organizations({ id: orgId }).get(); // ✅ → /organizations/:id
// api.organizations[orgId].get() // ❌ wrong
const { data } = await api.things({ thingId }).logs({ logId }).get(); // nested params
Server-only: when calling the backend from a server component/action without a service key, the client must forward the request cookie so the backend can verify the session. How that's wired (a per-request client, a header argument, etc.) is project-specific — check your @/lib/api setup.
Parallel queries — never await in a loop
Run independent reads concurrently with named results (e.g. better-all) or Promise.all:
const { rows, count } = await all({
async rows() {
return db.select().from(thing).where(filters);
},
async count() {
return db.$count(thing, filters);
},
});
// fan-out over a collection
await Promise.all(items.map((item) => processItem(item)));
Awaiting inside a for/while serializes independent work — use all() or Promise.all().
Filtered list tables — keepPreviousData
A search/filter/sort/page change is just a URL write (e.g. nuqs, shallow: true) that changes the queryKey, which drives a client useQuery refetch:
type/filter/sort/page → URL write → queryKey changes → useQuery refetch
placeholderData: keepPreviousData → previous rows stay on screen
loading = isPending || isPlaceholderData → ONLY body rows become skeletons
(toolbar, header row, pagination stay mounted and interactive)
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ["things", page, limit, q, sortBy, sortDir, statusFilter],
queryFn: () => listThings({ page, limit, q, sortBy, sortDir, status: statusFilter }),
placeholderData: keepPreviousData,
});
const loading = isPending || isPlaceholderData;
Why: without keepPreviousData, every keystroke flips isPending and the table blanks — losing the user's place. With it, prior rows stay while the new params fetch; scope the skeleton to the body rows only and keep the toolbar/header/pagination mounted. Debounce the search input (~400 ms) so it refetches on pause, not per keystroke.
Cache invalidation
Reads are invalidated by writes. After a successful mutation, invalidate the tag:
import { updateTag } from "next/cache";
updateTag(`${orgSlug}:things`); // matches the cacheTag in step 1
The full write pattern (server action throws → toast → updateTag, plus optimistic useMutation) lives in the next-mutations skill.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.