Products API: Listing, Search, Pagination, and Create

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.

The Products collection endpoint

In REST terms, /api/products represents a collection resource:

  • A GET request asks: “Give me a list of products” (optionally filtered and paginated).
  • A POST request 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).

Route handler overview: src/app/api/products/route.ts

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.

  • NextRequest is the Next.js request type used in Route Handlers. It gives you the request URL, headers, and body helpers like req.json().
  • success() and error() come from src/lib/http/response and ensure every response follows the same envelope shape (so clients don’t need special-case parsing).
  • listProducts, validateCreateProduct, and createProductService live in src/lib/services/productsService.ts. The route’s job is to call these and translate the result into HTTP responses.
Implementing GET list and search: GET /api/products

The GET handler supports optional query parameters:

  • query: a search string
  • page: 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 use searchParams to 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 null if missing), so we convert numeric ones with Number(...).
  • When a parameter is missing, we explicitly pass undefined. That’s important because the service layer (listProducts) applies defaults when values are undefined.
Delegating to the service and returning a success envelope

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 catch block is a safety net. If something unexpected happens in parsing or the service throws, the API returns a structured "INTERNAL_ERROR" with a 500 status instead of crashing the route handler.
  • Notice that this handler’s catch is generic (unlike the POST, which has special Postgres mapping). That means listing errors that bubble up here will become generic internal errors—fine for a baseline listing endpoint.
Service behavior behind listing: src/lib/services/productsService.ts

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 page and pageSize, the service decides what to do if they’re missing or invalid. Here, page defaults to 1 and pageSize defaults to 20.
  • The Math.min(params.pageSize, 100) cap prevents accidental or abusive requests like pageSize=100000. That’s a practical protection for database-backed endpoints.
  • The service delegates DB access to searchProducts (from src/lib/repositories/productsRepo), keeping SQL concerns out of the service.
How service exceptions become structured failures

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 an httpStatus: 500.
  • Even though the GET route currently doesn’t do Postgres error mapping like the POST route does, this function still matters: it ensures services don’t leak raw exceptions and encourages callers to handle outcomes intentionally.
Implementing POST create product: POST /api/products

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 in src/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 400 with "VALIDATION_ERROR", and it passes along valid.details if 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?”
Creating the product and mapping business outcomes

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). The 201 status 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.”
Mapping Postgres errors vs generic errors

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 using pgErrorToApiError.
  • pgErrorToApiError returns an object with code, message, optional details, and httpStatus, which the route forwards directly into the standard error(...) envelope.
  • If the error isn’t Postgres-specific, we fall back to "INTERNAL_ERROR" with status 500. 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 create validator in the service: validateCreateProduct

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 unknown because the request body is untrusted input. The whole point is to prove the shape before returning a typed CreateProductInput.
  • It validates required fields (sku, name, price_cents, inventory_count) using small reusable helpers like isString and , keeping rules consistent across the codebase.
The create service: conflict-aware creation in createProductService

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 with code: "CONFLICT" and httpStatus: 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) or fail(...), 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.
Recap

In this lesson, you implemented the two core behaviors of the Products collection endpoint:

  • GET /api/products parses query parameters and delegates listing/search/pagination rules to listProducts in src/lib/services/productsService.ts.
  • POST /api/products safely parses JSON, validates input with validateCreateProduct, creates with createProductService, 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() and error(), with POST additionally 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.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal