Lesson: Building Products API

Welcome! 👋 In this lesson, we’ll turn the products backend into a real, usable Products API that the app (and your Playground UI) can rely on.

You’ll see how Remix loaders and actions stay intentionally thin: they handle HTTP concerns (query params, JSON parsing, status codes), then delegate real work to the service layer. We’ll focus on two core behaviors that almost every backend needs early: listing products (with optional search + pagination) and creating a product (with validation and conflict handling). By the end, you’ll be able to trace a request from route → service → repository → database and understand why each layer exists.

The Products Collection Endpoint

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

  • GET /api/products means: “Give me a list of products” (optionally filtered and paginated).
  • POST /api/products means: “Create a new product inside this collection.”

In Remix, these two operations live in the same route module:

  • loader() handles read requests (GET).
  • action() handles mutations (POST, PUT, DELETE, etc.).

In this codebase, routes talk to services, not directly to SQL. That separation is what makes the API scalable: when you add product-by-id endpoints later, you’ll reuse the same services and repository patterns.

Route Overview: app/routes/api.products.ts

The file app/routes/api.products.ts is the entry point for /api/products. It exports both a loader and an action, and it relies on shared helpers for response envelopes and safe JSON parsing.

Route dependencies:

This chunk shows what the route depends on and hints at the architecture: the route imports services, not repositories.

  • LoaderFunctionArgs and ActionFunctionArgs are Remix types describing what Remix passes into loader and action. The key thing you’ll use here is the request, which is the standard Web Request.
  • success() and error() come from src/lib/http/response.ts and guarantee that every API response uses the same envelope ({ data, meta } or ).
Listing Products: GET /api/products in the Loader

The loader() implements listing with optional search and pagination. It supports these query parameters:

  • query: optional search string (SKU or name matching happens deeper in the repository)
  • page: optional page number (1-based)
  • pageSize: optional number of results per page (capped by validation + service rules)

Validating query params and calling the service:

This code lives in app/routes/api.products.ts. The route validates inputs before calling the service.

  • new URL(request.url) is the standard way to parse a Remix Request URL and read query parameters via searchParams.

Service-Side Listing: Defaults, Limits, and Error Conversion

The loader delegates listing behavior to the service in src/lib/services/productsService.ts. The service applies defaults and caps, then calls the repository.

The listProducts service:

  • Even though the route validates page and pageSize, the service still applies defaults. This is intentional: services are reusable, so if another route calls listProducts later, it still gets consistent behavior.
  • page defaults to 1 and pageSize defaults to 20, which are common “reasonable defaults” for listing endpoints.
  • Math.min(params.pageSize, 100) is a second line of defense. Even if another caller forgets to validate, the service avoids unbounded page sizes.
  • The service delegates all data access to searchProducts(...) in the repository layer, keeping SQL out of services.
  • Instead of throwing, the service returns ok(...) or (wrapped in ), which makes error handling explicit for routes.
Repository Listing: SQL Search + Pagination + Mapping

The repository module src/lib/repositories/productsRepo.ts owns SQL and row mapping. This is where search and pagination are implemented in SQL terms.

Search with optional query + pagination:

  • Pagination uses the classic LIMIT/OFFSET pattern, with offset = (page - 1) * pageSize.
  • Search uses ILIKE (case-insensitive matching) and checks both SKU and name.
  • The SQL uses $1, $2, $3 placeholders with a separate params array. This is important for safety and correctness (it prevents SQL injection and avoids quoting bugs).
  • The repository always maps rows through mapProductRow, so the rest of the system only deals with the Product domain model.
Creating Products: POST /api/products in the Action

Creating is handled by the route action(). It needs to safely parse JSON, validate the body, call the create service, and map results to the correct HTTP status codes.

Safe JSON parsing + validation + creation flow:

This code lives in app/routes/api.products.ts.

  • Remix actions can handle multiple mutation methods, so this route explicitly checks request.method and returns a 405 if it’s not POST. That keeps behavior predictable.
  • parseJson(request) is safer than calling request.json() directly because it converts malformed JSON into a structured { ok: false, code, message } result instead of throwing.
  • validateCreateProduct(...) turns untrusted JSON into a typed only if all validation checks pass.
Service Validation: Turning Unknown JSON into a Typed DTO

The validator validateCreateProduct lives in src/lib/services/productsService.ts. It’s your gatekeeper: it ensures the service only receives safe, typed input.

validateCreateProduct:

  • The function accepts unknown because request bodies are untrusted by default. The whole point is to prove shape and types before returning a safe DTO.

  • The checks use shared validation helpers:

    • isString ensures required strings exist and aren’t empty.
    • isInt ensures numbers are true integers (not floats or NaN) and meet min bounds.
    • isEnum ensures currency is one of allowed values (USD for now).
Create Service: Enforcing Business Rules and Writing to the Database

The create service enforces domain rules (like unique SKU) and then inserts the product through the repository.

createProductService:

  • The service checks for SKU uniqueness by calling getProductBySku. This is a business rule, so it belongs in the service layer, not in the route.
  • If the SKU already exists, the service returns a CONFLICT failure with HTTP status 409. The route simply forwards this.
  • The service generates the product ID using randomUUID(). This keeps ID generation server-side, which is the common approach for APIs (clients shouldn’t be responsible for IDs).
  • The actual insert happens in the repository function createProduct(...), keeping SQL out of the service.
  • Errors are converted through handleException, so Postgres errors become structured failures instead of raw thrown exceptions.
Repository Create and Archive: SQL for Writes

The repository contains the SQL for inserts and updates. This lesson focuses on create (and shows where archive fits into CRUD).

Creating a product row:

  • INSERT INTO ... RETURNING * is a great pattern for APIs because you get the created row back immediately (including timestamps/defaults).
  • COALESCE($8,'active') ensures status defaults to "active" even if the caller doesn’t pass one.
  • The function returns a mapped Product domain model, not a raw row, keeping the rest of the codebase consistent.
  • Parameters are passed as an array, which keeps the query safe and avoids string concatenation issues.
Archiving a product (part of CRUD)
  • This is the “soft delete” approach: rather than deleting rows, you set status='archived'.
  • Returning null when no row exists makes it easy for the service to return a NOT_FOUND error.
  • This function is the basis for the “D” (delete/archive) part of CRUD, and you’ll build the corresponding route behavior in later lessons.
Recap

In this lesson, you built the backbone of a real Products API:

  • GET /api/products validates query params, delegates listing to listProducts, and returns a consistent Product[] success envelope.
  • POST /api/products safely parses JSON, validates input, calls createProductService, and returns correct HTTP outcomes (400, 409, 201), always with consistent envelopes.
  • Validation is centralized in validateCreateProduct so the service never accepts untrusted input.
  • SQL lives in the repository layer (searchProducts, createProduct, archiveProduct), while services enforce business rules (like unique SKU) and routes handle HTTP mechanics.

From here, expanding to full CRUD (by-id reads, updates, and archiving endpoints) becomes a repeatable pattern rather than a new design problem each time.

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