Next queries
Agent skills for stacks conventions
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.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
7.4 KB, 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.