End-to-End Wiring: The Respond Flow
- Wire a client form to a Server Action using useActionState for pending/success/error state
- Use .bind() to partially apply a Server Action argument that doesn't come from form fields
- Recognize that Server Actions need the same auth/authorization checks as a Route Handler, since both are reachable via direct POST
- Use revalidatePath to refresh server-rendered pages after a mutation without client-side refetching
- Explain why this app intentionally has both a REST endpoint and a Server Action implementing similar logic
- Trace one feature (respond to a review) across all the pieces built in lessons 8-10
Closing the Loop
Lessons 8-10 built the pieces in isolation: a Route Handler that can save a response, a detail page that displays a review, and the case for keeping state minimal. This lesson wires them into one feature a reader can actually click through -- typing a reply on a review and watching it post without a full page reload.
The Client Form: respond-form.tsx
app/dashboard/reviews/[id]/respond-form.tsx is the one component in this section that genuinely needs "use client", because it needs to show a pending state while the request is in flight and a success state afterward, without navigating away:
"use client";
import { useActionState } from "react";
import { respondToReview, type RespondFormState } from "./actions";
const initialState: RespondFormState = {};
export function RespondForm({ reviewId }: { reviewId: string }) {
const action = respondToReview.bind(null, reviewId);
const [state, formAction, pending] = useActionState(action, initialState);
if (state.success) {
return Response posted.
;
}
return (
);
}
useActionState is React's built-in hook for exactly this shape of problem: a form backed by a Server Action, where you need the action's return value (success or error) plus a pending boolean, without manually wiring useState and onSubmit handlers yourself. respondToReview.bind(null, reviewId) partially applies the review id so the form only needs to submit the textarea's value -- the id travels with the bound function, not as a hidden input.
The Server Action: actions.ts
app/dashboard/reviews/[id]/actions.ts starts with "use server" at the top of the file, marking every exported function in it as a Server Action callable from a client form:
"use server";
export async function respondToReview(
reviewId: string,
_prevState: RespondFormState,
formData: FormData,
): Promise {
const user = await getCurrentUser();
if (!user) {
return { error: "You must be signed in." };
}
const parsed = respondSchema.safeParse({ body: formData.get("body") });
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const [row] = await db
.select({ reviewId: reviews.id, businessOwnerId: businesses.ownerId })
.from(reviews)
.innerJoin(businesses, eq(reviews.businessId, businesses.id))
.where(eq(reviews.id, reviewId))
.limit(1);
if (!row || row.businessOwnerId !== user.id) {
return { error: "Review not found." };
}
await db.insert(responses).values({ reviewId, body: parsed.data.body });
await db.update(reviews).set({ status: "responded" }).where(eq(reviews.id, reviewId));
revalidatePath(`/dashboard/reviews/${reviewId}`);
revalidatePath("/dashboard");
return { success: true };
}
Notice this is the same authorization join from the API route in lesson 8 -- confirm the review's business belongs to the caller before writing anything. A Server Action is reachable via a direct POST request just like a Route Handler is, not only through your UI, so it needs the identical authentication and authorization checks. After the writes, revalidatePath tells Next.js to invalidate the cached detail page and the dashboard list, so the next render reflects the new response and the status change to "responded" -- without a client-side refetch.
Why Both a Route Handler and a Server Action Exist for "Respond"
You may have noticed app/api/reviews/[id]/respond/route.ts from lesson 8 and app/dashboard/reviews/[id]/actions.ts do nearly identical work -- same validation, same authorization join, same two writes. That duplication is deliberate, not an oversight: the Route Handler is the stable, documented integration point for anything outside the Next.js app itself (a future mobile client, a webhook-driven automation, an API consumer), while the Server Action is what the in-app form calls directly, because a form invoking a Server Action gets progressive enhancement and pending-state ergonomics a Route Handler does not give you for free. In a smaller app you might pick one and call it from both places; here, keeping them separate keeps the public API surface and the UI's internal wiring independently changeable.
Ask Claude Code: "Compare app/api/reviews/[id]/respond/route.ts and app/dashboard/reviews/[id]/actions.ts -- are the authorization checks identical? If I change the authorization logic, what's the risk of only updating one of them?"
That prompt is worth running for real: any time the same business rule lives in two places, drift is the risk, and a reader should leave this lesson knowing where their own future apps might choose to consolidate to one canonical function instead.
What You Just Built
End to end: a signed-in owner opens /dashboard/reviews/[id] (lesson 9), sees the review rendered with the shared badge components (lesson 9), types a reply into a client form (this lesson), submits through a Server Action that re-runs the same authorization and validation as the REST endpoint (lessons 8 and 11), and sees the page update via revalidatePath with no full reload and no client state library involved (lesson 10's argument, proven out). That is the full feature, working.
- useActionState gives a form pending boolean and a typed result object without manual useState/onSubmit wiring
- Server Actions are reachable via direct POST requests, not just through your app's UI -- they need the same auth checks as any API route
- revalidatePath invalidates a server-rendered route's cache so the next visit shows fresh data, without a client-side fetch
- A Route Handler and a Server Action can legitimately coexist for the same operation: one is the stable external API, the other is the form's direct wiring
- Duplicated business logic across two entry points is a real maintenance risk worth naming, even when the duplication is an intentional tradeoff
- Two database writes (insert response, then update status) without a wrapping transaction is acceptable when a partial failure is self-correcting on the next read