◆ Convex Stack Bootcamp
Day 6 of 7

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.

Time: ~90 min · Video: Fullstack app with Next.js, Convex, ShadCN (59:53) · You ship: a server-rendered page that is still reactive after hydration.


#1. Four functions, two questions

You needUseReactive after load?
SSR and live updatespreloadQuery → usePreloadedQuery✅
Server-side read, one shotfetchQuery❌
Write from a Server ActionfetchMutationn/a
Client-side readuseQuery✅ (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. useQuery works elsewhere in the app. Where do you look first?

Answer

For a fetchQuery in the Server Component. It reads once; nothing subscribes. Swap to preloadQuery + usePreloadedQuery. Do not start by debugging the socket — the socket is fine.

#Quick check 2

Why does preloadQuery need 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. preloadQuery produces a serialisable handle; usePreloadedQuery picks 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; preloadQuery running on the server does not, so getUserIdentity() returns null and your guard throws. Pass { token } as the third argument.


#Build task

Port the link saver to the Next.js App Router.

  1. /links as a Server Component using preloadQuery, with a Client Component consuming it via usePreloadedQuery. Confirm with view-source that the links are in the HTML.
  2. Two windows again. Confirm SSR and liveness together.
  3. 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.
  4. Wire Day 5's auth through the server call with { token }. Remove the token on purpose and watch SSR throw.
  5. Add one Server Action form using fetchMutation and disable JavaScript to see it still work.

#Checkpoint

  1. Which pair gives SSR and reactivity, and what does each half do?
  2. What exactly is wrong with fetchQuery for live data, and why is it hard to notice?
  3. Name a case where fetchQuery is the correct choice.
  4. Why must the auth token be passed explicitly server-side?
  5. Tradeoff between a Server Action + fetchMutation and a client useMutation?

#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.

Stored in this browser only.