Welcome! 👋 In this lesson, we’ll turn the “products” backend into a real, usable API endpoint that a frontend (or your Playground UI) can rely on.
You’ll see how Next.js Route Handlers act as thin HTTP “controllers” that parse requests and delegate real work to the service layer. We’ll focus specifically on two behaviors: listing products (with optional search and pagination) and creating a product (with validation and conflict handling). By the end, you’ll be able to trace a request from the route handler → service → repository/database (and back) and understand why each layer has a clear job.
In REST terms, /api/products represents a collection resource:
- A
GETrequest asks: “Give me a list of products” (optionally filtered and paginated). - A
POSTrequest says: “Create a new product in this collection.”
In this codebase, the route handler is responsible for the HTTP mechanics (reading query params, parsing JSON, returning status codes), while business rules and defaults live in the service layer. That division keeps your API easier to extend later (for example, when you add /api/products/:id endpoints).
This file is the entry point for /api/products. It exports functions named after HTTP methods (GET, POST), which Next.js automatically wires up to incoming requests.
This first chunk shows what the route handler depends on. You’ll notice it doesn’t import repositories or SQL—routes talk to services, not directly to the database.
NextRequestis the Next.js request type used in Route Handlers. It gives you the request URL, headers, and body helpers likereq.json().success()anderror()come fromsrc/lib/http/responseand ensure every response follows the same envelope shape (so clients don’t need special-case parsing).listProducts,validateCreateProduct, andcreateProductServicelive insrc/lib/services/productsService.ts. The route’s job is to call these and translate the result into HTTP responses.
The GET handler supports optional query parameters:
query: a search stringpage: which page to fetch (1-based)pageSize: how many items per page (capped in the service)
This snippet is the “HTTP parsing” part of the handler: extract raw values from the URL and convert them into the types the service expects.
new URL(req.url)lets us usesearchParamsto read query-string values safely. This is the standard way to access?query=...&page=...in Route Handlers.- Each param comes in as a string (or
nullif missing), so we convert numeric ones withNumber(...). - When a parameter is missing, we explicitly pass
undefined. That’s important because the service layer (listProducts) applies defaults when values areundefined.
Now that we have the parsed inputs, we call the service and return a consistent response envelope.
listProducts({ query, page, pageSize })is where the “real” listing behavior happens (default pagination, page-size limits, and repository calls).success(products)wraps the returned value in the shared response format. That way, a frontend always receives a predictable JSON shape.- The
catchblock is a safety net. If something unexpected happens in parsing or the service throws, the API returns a structured"INTERNAL_ERROR"with a500status instead of crashing the route handler. - Notice that this handler’s
catchis generic (unlike thePOST, which has special Postgres mapping). That means listing errors that bubble up here will become generic internal errors—fine for a baseline listing endpoint.
The GET route depends on listProducts, so let’s look at the service-side logic that makes listing predictable and safe.
This code lives in src/lib/services/productsService.ts. It ensures the API behaves consistently even if callers pass weird or missing pagination values.
- Even though the route passes
pageandpageSize, the service decides what to do if they’re missing or invalid. Here,pagedefaults to1andpageSizedefaults to20. - The
Math.min(params.pageSize, 100)cap prevents accidental or abusive requests likepageSize=100000. That’s a practical protection for database-backed endpoints. - The service delegates DB access to
searchProducts(fromsrc/lib/repositories/productsRepo), keeping SQL concerns out of the service.
This helper is what listProducts falls back to in the catch. It’s important because it centralizes error translation.
- If a Postgres error occurs, the service turns it into a structured failure via
mapPostgresError. That means “database weirdness” becomes predictable for callers. - If the error isn’t Postgres-specific, the service still returns a consistent
"INTERNAL_ERROR"with anhttpStatus: 500. - Even though the
GETroute currently doesn’t do Postgres error mapping like thePOSTroute does, this function still matters: it ensures services don’t leak raw exceptions and encourages callers to handle outcomes intentionally.
Creating a product has more moving parts than listing:
- The route must parse JSON safely.
- The service-level validator must enforce required fields and types.
- The service must enforce business rules like unique SKU.
- The route must map outcomes to correct HTTP status codes.
This code lives in src/app/api/products/route.ts. It reads the body and validates it before calling the create service.
await req.json().catch(() => null)is a deliberate “safe parse” pattern. If the client sends malformed JSON, we don’t want an exception to skip our normal response formatting.validateCreateProduct(body)lives insrc/lib/services/productsService.ts. It checks both presence (required fields) and types (e.g., integers for cents and inventory).- When validation fails, this route returns a
400with"VALIDATION_ERROR", and it passes alongvalid.detailsif the validator provided any additional structured info. - The key design idea: the route is responsible for HTTP-level decisions (like status code 400), while the validator is responsible for “is this data shaped correctly?”
Once validation passes, the route calls the service and maps the service result to the appropriate response.
createProductService(valid.value)enforces business rules and performs the database insert (through repositories).- If the service returns
{ ok: false, ... }, this route maps it to a 409 Conflict. In this codebase, that conflict represents a SKU uniqueness violation (“SKU already exists”). - If creation succeeds, we return
success(created.value, 201). The201status matters: it communicates “a new resource was created” (not just “request succeeded”). - Notice how the route never constructs product IDs, never checks SKU uniqueness directly, and never talks to SQL. Its only job is to translate “service outcomes” into “HTTP outcomes.”
The POST route includes a more detailed catch block than GET. This is where database errors get turned into API-safe error payloads.
isPostgresError(err)detects whether the thrown error is a known Postgres-shaped error. If it is, we convert it into an API error usingpgErrorToApiError.pgErrorToApiErrorreturns an object withcode,message, optionaldetails, andhttpStatus, which the route forwards directly into the standarderror(...)envelope.- If the error isn’t Postgres-specific, we fall back to
"INTERNAL_ERROR"with status500. This keeps the API stable and avoids leaking internal exception details in an inconsistent format. - The big win here is predictability: clients can trust that even “bad” situations come back as structured errors with meaningful codes.
The route calls validateCreateProduct(body) before it ever tries to create a product. This function lives in src/lib/services/productsService.ts and acts as the gatekeeper for input correctness.
- The function accepts
unknownbecause the request body is untrusted input. The whole point is to prove the shape before returning a typedCreateProductInput. - It validates required fields (
sku,name,price_cents,inventory_count) using small reusable helpers likeisStringand , keeping rules consistent across the codebase.
Once validation passes, creation goes through createProductService in src/lib/services/productsService.ts. This is where business rules live (like SKU uniqueness), and where IDs are generated server-side.
- The uniqueness check
getProductBySku(input.sku)happens before insertion, and if it finds an existing product, the service returns a structured failure withcode: "CONFLICT"andhttpStatus: 409. randomUUID()generates the product ID in the backend. This is a common backend practice: clients shouldn’t be responsible for ID generation in most REST APIs.- The service sets
status: "active"for new products, which is a domain/business decision (not an HTTP decision), so it belongs here rather than in the route. - The service returns
ok(product)orfail(...), keeping the outcome explicit and easy to map to HTTP responses at the route layer. - Any thrown exception gets converted through , keeping service boundaries clean and consistent.
In this lesson, you implemented the two core behaviors of the Products collection endpoint:
GET /api/productsparses query parameters and delegates listing/search/pagination rules tolistProductsinsrc/lib/services/productsService.ts.POST /api/productssafely parses JSON, validates input withvalidateCreateProduct, creates withcreateProductService, and maps outcomes to clear HTTP statuses (400,409,201).- The route handler stays thin and focused on HTTP, while the service layer owns defaults, business rules, and conflict detection.
- Errors are returned in consistent envelopes via
success()anderror(), withPOSTadditionally mapping Postgres errors into structured API errors.
This Route → Service pattern is the backbone you’ll reuse for every other resource you build in this project.
