API Routes for Review Ingestion and Responses
- Build Next.js Route Handlers for listing, ingesting, and responding to reviews
- Validate request bodies and query params with Zod before touching the database
- Scope every query to the authenticated user's own business, never a client-supplied id
- Use a unique index plus onConflictDoNothing for idempotent review ingestion
- Implement an authorization boundary with a join, not just an existence check
- Read the Promise-based params convention for dynamic Route Handler segments
From Schema to Endpoints
You now have auth and a schema (lessons 5-7): a businesses table with one row per owner, a reviews table, and a responses table. This lesson turns that schema into three working Route Handlers -- Next.js App Router's convention for API endpoints defined in route.ts files. Ask Claude Code to build each one against the real schema rather than a generic CRUD template; the validation and authorization details below are what make these endpoints safe to ship, not boilerplate to skip.
Listing Reviews: app/api/reviews/route.ts
The dashboard needs reviews filtered by status, filtered by source, and sorted -- so the GET handler validates query params with Zod before touching the database:
const querySchema = z.object({
status: z.enum(["new", "responded", "flagged", "archived"]).optional(),
source: z.enum(["google", "yelp", "facebook"]).optional(),
sort: z.enum(["newest", "oldest", "rating_asc", "rating_desc"]).default("newest"),
});
export async function GET(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ...look up the caller's business, parse params, build conditions
}
Two things worth noticing. First, the handler never trusts a businessId from the client -- it looks up the business owned by user.id and scopes every query to that. Second, params are parsed with request.nextUrl.searchParams -- the convenience API NextRequest adds on top of the standard Web Request. An invalid sort or status value returns 400 with Zod's flattened error details, not a silent fallback.
Ingestion: app/api/reviews/ingest/route.ts
In production this endpoint would be called by a scheduled job pulling from the Google Places API, Yelp Fusion API, or Facebook Graph API. Each of those requires business verification or app review that is not feasible inside a self-paced tutorial, so this build does not include a real poller -- but the ingestion endpoint, its validation, and its dedupe logic are real and are the actual integration point you would wire a poller into later. The reviews you will see in the dashboard come from db/seed.ts, a script that inserts realistic mock review data directly.
The endpoint validates a strict shape and rate-limits by IP before it does anything else:
const ingestSchema = z.object({
businessId: z.string().uuid(),
source: z.enum(["google", "yelp", "facebook"]),
externalId: z.string().min(1).max(255),
authorName: z.string().min(1).max(255),
rating: z.number().int().min(1).max(5),
body: z.string().min(1).max(5000),
postedAt: z.string().datetime(),
});
Dedup is handled at the database level, not in application code: (business_id, source, external_id) is a unique index, and the insert uses .onConflictDoNothing() targeting that index. Re-ingesting the same platform review twice is a no-op, which matters because a real poller will re-fetch overlapping date ranges on every run.
Ask Claude Code: "Add a unique index on (business_id, source, external_id) to the reviews table, then update the ingest route to use onConflictDoNothing targeting it instead of checking for duplicates in application code."
The route also calls canIngestReview(business) (covered in lesson 12) to enforce the free-tier review cap, and fires a best-effort negative-review alert email without blocking the response -- a pattern lesson 14 covers in depth.
Responding: app/api/reviews/[id]/respond/route.ts
This is the most important authorization lesson in this section. A signed-in user could try to respond to any review by guessing or enumerating UUIDs, so the handler does not just check "is this review id valid" -- it joins through to confirm the review's business is owned by the caller:
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) {
return NextResponse.json({ error: "Review not found" }, { status: 404 });
}
if (row.businessOwnerId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
That join is the authorization boundary. Without it, the only check would be "does a review with this id exist," which is not the same question as "does this review belong to you." On success the handler inserts into responses and flips the review's status to "responded" in a second update -- two writes, not a single atomic transaction, which is fine here because a partial failure (response saved, status not yet flipped) is self-correcting on the next read.
A Note on Route Handler Conventions
All three handlers follow the same Next.js App Router shape: an exported async function named after the HTTP verb (GET, POST), a typed NextRequest parameter, and for dynamic segments like [id], a second argument where params is a Promise you must await -- not a plain object, as it was in older Next.js versions. Every handler in this build follows the same shape: validate input, authenticate, authorize, then touch the database -- in that order, so an unauthenticated or malformed request never reaches a query.
Next, lesson 9 builds the UI that calls these routes -- the dashboard list and detail views.
- Route Handlers export async functions named GET/POST/etc. in a route.ts file -- that naming is the only wiring required
- Dynamic segment params (e.g. [id]) are a Promise in current Next.js and must be awaited before use
- A unique index plus onConflictDoNothing pushes dedupe logic into the database instead of a racy read-then-write in application code
- Checking that a review id exists is not the same as checking that it belongs to the caller -- join through to the owning business for real authorization
- Best-effort side effects like alert emails should be fired without awaiting them inline so a third-party failure never fails the primary request
- The reference app's reviews come from a seed script, not a live platform API -- ingestion is realistic but intentionally mocked for this tutorial