Now that checkout can turn an open cart into a pending order, we need a way to actually see what was created. This lesson focuses on the two most common “read” operations in any backend: listing many resources (a collection) and fetching one resource (a single record).
You’ll implement and understand the two Orders endpoints our project exposes: GET /api/orders for paginated lists and GET /api/orders/:id for order details. Along the way, you’ll see how query params and route params are validated at the route layer, while the service layer handles defaults, caps, and “not found” behavior consistently.
Previously: From Cart to Order
In the previous lesson, we implemented checkout as a transactional workflow that snapshots cart items into order items, creates an order with status pending, and marks the cart as checked_out so it can’t be edited anymore. That gave us reliable order records in the database—now we’re building the read side so clients can browse and inspect those records.
There are two ways clients typically access orders:
- Listing orders (collection): show a page of results, usually newest first.
- Fetching one order (resource): show details for a specific order, including its items.
In this project, those map to two Next.js route handlers:
src/app/api/orders/route.ts→GET /api/orderssrc/app/api/orders/[id]/route.ts→GET /api/orders/:id
Both routes follow the same overall pattern: validate inputs early, call a service function, and return a consistent API envelope using success(...) and error(...).
The list endpoint must be safe and scalable. Even a small shop can quickly accumulate thousands of orders, so we never want to return “all orders” in one response. Instead, we paginate using page and pageSize query parameters.
Route: parse and validate query parameters
This code lives in src/app/api/orders/route.ts. Its job is to read query params from the URL, validate them, then delegate to the service layer.
req.nextUrl.searchParamsis the Next.js-friendly way to access query parameters in route handlers. It gives us aURLSearchParamsobject, which is exactly what our validation helper expects.parseOptionalIntParam(...)does the heavy lifting of converting string values into numbers and enforcing bounds. Themin: 1forpageprevents invalid pages like or , which would make pagination math nonsensical.
src/lib/services/ordersService.ts is where we enforce the “product-level” rules for listing: default paging, max page size, and consistent error handling if anything goes wrong.
Service: normalize pagination and call the repository
- The service accepts
{ page?: number; pageSize?: number }so it can be used safely from multiple entry points (HTTP routes, tests, internal calls). That’s why it can’t assume the route validated everything perfectly—it defensively normalizes the values anyway. pagedefaults to1when missing or invalid. This means the “first page” is always reachable even if a client forgets to sendpage, and it avoids surprising behavior like a0offset frompage=0.pageSizedefaults to20, which is a reasonable “page” for many UIs. More importantly, it appliesMath.min(params.pageSize, 100)so page sizes are always capped at 100, even if a future route forgets to enforce the max.- The only job after normalization is calling the repository and wrapping the result in . This keeps business logic out of the repository while still ensuring repository calls are safe and predictable.
Fetching one order is a classic resource route: it uses a dynamic segment ([id]) and returns either the order object or a NOT_FOUND.
Route: validate the UUID route param
This code lives in src/app/api/orders/[id]/route.ts. Its job is to extract id from the route params and validate that it’s a UUID before calling the service.
- The route uses the modern Next.js params pattern:
const { id } = await context.params;. In this project,paramsis typed as aPromise, so this is the correct, codebase-aligned way to access it. isUUID(id)is an early guardrail that prevents unnecessary database traffic. If a client sends/api/orders/not-a-real-id, we immediately return a 400 rather than performing a query that cannot possibly succeed.- After validation, the route delegates to . This keeps the route thin and focused on HTTP concerns (params, status codes, response envelopes).
The service function getOrderService is the “single source of truth” for what should happen when an order is missing. The route just forwards that result.
getOrderById(id)returns either anOrderornull, which is a clean repository contract: “missing data is not an exception.” The service is responsible for turning that into an API-friendly 404.- The
NOT_FOUNDerror includeshttpStatus: 404, which means the route doesn’t need to guess the correct status code. It simply forwardsorder.error.httpStatus. - Returning
ok(order)keeps success results consistent with all other services in the codebase. This uniformServiceResultshape is what makes routes simple and predictable. - The
catchblock again useshandleException(e), ensuring that unexpected failures become structured errors rather than bubbling up as unhandled exceptions. This helps keep your API responses consistent even under database or runtime issues.
In this unit, we added the read side of our Orders API:
GET /api/orders(src/app/api/orders/route.ts) lists orders with pagination, validating query params and delegating tolistOrdersService.listOrdersService(src/lib/services/ordersService.ts) enforces safe defaults and caps (page=1,pageSize=20, max 100) before calling the repository.GET /api/orders/:id(src/app/api/orders/[id]/route.ts) fetches a single order, validating thatidis a UUID before callinggetOrderService.getOrderServiceconsistently returns a 404NOT_FOUNDwhen the order doesn’t exist, and wraps all other failures through shared exception handling.
With these endpoints in place, the system can now not only create orders during checkout, but also retrieve them for order history screens, admin dashboards, and customer order detail pages.
