Interview question
Split a Next.js page at the interaction boundary
Keeps data and secrets on the server while sending a small serializable model to an interactive client component.
TL;DR
Keeps data and secrets on the server while sending a small serializable model to an interactive client component.
Server components, client components, serialization, data access, secrets, and bundle boundaries.
Practice the problem like a real interview: restate, reason, implement, and test.
The page loads drill details and renders a favorite button. Fetch data in the server component; pass only id and initial favorite state to the client button.
Next in Next.js Boundaries: Capability-Aware UI
The drill body ships as server-rendered HTML while only the small favorite control hydrates.
I place use client at the smallest interactive leaf. The server component owns data access and rendering; the button owns transient interaction.
export default async function DrillPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const drill = await loadAuthorizedDrill(slug);
return <main>
<h1>{drill.title}</h1>
<article>{drill.answer}</article>
<FavoriteButton drillId={drill.id} initialFavorite={drill.isFavorite} />
</main>;
}
// FavoriteButton.tsx
"use client";
import { updateFavoriteAction } from "./actions";
export function FavoriteButton(props: { drillId: string; initialFavorite: boolean }) {
const [favorite, setFavorite] = useState(props.initialFavorite);
const [pending, setPending] = useState(false);
async function toggle() {
const previous = favorite;
const next = !previous;
setFavorite(next);
setPending(true);
try { await updateFavoriteAction(props.drillId, next); }
catch { setFavorite(previous); }
finally { setPending(false); }
}
return <button aria-pressed={favorite} disabled={pending} onClick={toggle}>
{favorite ? "Remove favorite" : "Add favorite"}
</button>;
}
// actions.ts
"use server";
export async function updateFavoriteAction(drillId: string, favorite: boolean) {
const user = await requireCurrentUser();
await requireCapability(user, "practice.write.own");
await saveFavorite(user.id, drillId, favorite);
}
The client bundle excludes page data-loading code and large static answer content. The small client leaf persists through an authorized server action and rolls back a failed optimistic change.
Spot a weak answer, missing edge case, or clearer explanation? Send it in.