Day 6 — Next.js App Router
The one idea. "Render it on the server" and "keep it live" are two separate asks, and Convex gives you a different function for each. Picking the wrong one produces a page that looks perfect and silently stops updating.
#1. Four functions, two questions
| You need | Use | Reactive after load? |
|---|---|---|
| SSR and live updates | preloadQuery → usePreloadedQuery | ✅ |
| Server-side read, one shot | fetchQuery | ❌ |
| Write from a Server Action | fetchMutation | n/a |
| Client-side read | useQuery | ✅ (no SSR) |
// app/page.tsx — Server Component
import { preloadQuery } from "convex/nextjs";
export default async function Page() {
const preloadedTasks = await preloadQuery(api.tasks.list, { list: "default" });
return <TaskList preloadedTasks={preloadedTasks} />;
}// TaskList.tsx — Client Component
"use client";
import { usePreloadedQuery } from "convex/react";
export function TaskList(props) {
const tasks = usePreloadedQuery(props.preloadedTasks);
// rendered on the server, live from here on
}The handoff: the server runs the query and ships the result in the HTML, so there is no
loading flash and crawlers see content. usePreloadedQuery then adopts that result as its
initial value and subscribes — from hydration onward it behaves exactly like useQuery.
#2. The fetchQuery trap
const tasks = await fetchQuery(api.tasks.list, { list: "default" });This is a one-shot read. Correct data, rendered on the server, frozen forever.
The story. fetchQuery is a photograph; preloadQuery is a window. Both look identical
the instant you take them. You only discover which one you installed when something outside
moves and the picture doesn't.
This bug is expensive precisely because nothing goes wrong. No error, no warning, no console message. The page is right on load and quietly wrong ten seconds later, and it usually gets reported as "realtime is flaky" — sending you off to debug the wrong layer entirely.
fetchQuery is the right tool when the data genuinely isn't live: a metadata block, an
OG-image generator, a static marketing page, anything inside generateMetadata.
#3. Server Actions
"use server";
import { fetchMutation } from "convex/nextjs";
export async function createTask(formData: FormData) {
await fetchMutation(api.tasks.create, {
text: formData.get("text") as string,
});
}Useful for progressive enhancement — forms that work without JavaScript. Note the tradeoff:
you lose the optimistic, instantly-reactive feel of calling useMutation from the client.
For an app that is already interactive, useMutation is usually the better call; Server
Actions earn their place when the form must work before hydration.
#4. Auth across the boundary
Server-side calls have no browser session. Pass the token explicitly:
const preloadedTasks = await preloadQuery(
api.tasks.list,
{ list: "default" },
{ token },
);With Clerk: const token = (await auth()).getToken() ?? undefined;
With Auth0: session.tokenSet.idToken from getSession().
Forget the token and ctx.auth.getUserIdentity() returns null inside your query — so a
Day 5 handler that throws on null will throw during SSR. The symptom is "my page 500s on
the server but works fine on the client," and the cause is almost always a missing token.
On the client, wrap with the auth-aware provider (ConvexProviderWithClerk and friends) so
the browser's socket carries the identity too. Two different transports, two different
places to attach the same token.
#Quick check 1
Your dashboard renders correct numbers on load and never changes again.
useQueryworks elsewhere in the app. Where do you look first?Answer
For a
fetchQueryin the Server Component. It reads once; nothing subscribes. Swap topreloadQuery+usePreloadedQuery. Do not start by debugging the socket — the socket is fine.
#Quick check 2
Why does
preloadQueryneed a Client Component partner at all? Why can't a Server Component just be reactive?Answer
Server Components render once, on the server, and ship HTML — there's no persistent process holding a subscription for that user. Liveness requires something long-lived in the browser.
preloadQueryproduces a serialisable handle;usePreloadedQuerypicks it up on the client and opens the subscription.
#Quick check 3
A Day 5 handler throwing "Unauthorized" during SSR but not in the browser. Cause?
Answer
No token on the server call. The browser has the session;
preloadQueryrunning on the server does not, sogetUserIdentity()returnsnulland your guard throws. Pass{ token }as the third argument.
#Build task
Port the link saver to the Next.js App Router.
/linksas a Server Component usingpreloadQuery, with a Client Component consuming it viausePreloadedQuery. Confirm with view-source that the links are in the HTML.- Two windows again. Confirm SSR and liveness together.
- Deliberate bug: swap in
fetchQuery. Confirm the initial render is identical and updates stop. Then swap back. This is the day's most valuable five minutes. - Wire Day 5's auth through the server call with
{ token }. Remove the token on purpose and watch SSR throw. - Add one Server Action form using
fetchMutationand disable JavaScript to see it still work.
#Checkpoint
- Which pair gives SSR and reactivity, and what does each half do?
- What exactly is wrong with
fetchQueryfor live data, and why is it hard to notice? - Name a case where
fetchQueryis the correct choice. - Why must the auth token be passed explicitly server-side?
- Tradeoff between a Server Action +
fetchMutationand a clientuseMutation?
#Cheat strip
SSR + live : preloadQuery(api.x.y, args) → usePreloadedQuery(preloaded)
SSR only : fetchQuery(api.x.y, args) ← photograph, never updates
write (SA) : fetchMutation(api.x.y, args)
client : useQuery / useMutation
auth (server): preloadQuery(api.x.y, args, { token })
clerk: (await auth()).getToken() ?? undefined auth0: session.tokenSet.idToken
auth (client): ConvexProviderWithClerk / equivalent
symptom → cause: "renders right, never updates" → fetchQuery
"500s on server, fine on client" → missing { token }#Tomorrow
Day 7: everything you built is correct at 50 rows. That is not a claim about 500,000.