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.
In REST terms, /api/products is a collection resource:
GET /api/productsmeans: “Give me a list of products” (optionally filtered and paginated).POST /api/productsmeans: “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.
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.
LoaderFunctionArgsandActionFunctionArgsare Remix types describing what Remix passes intoloaderandaction. The key thing you’ll use here is therequest, which is the standard WebRequest.success()anderror()come fromsrc/lib/http/response.tsand guarantee that every API response uses the same envelope ({ data, meta }or ).
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 RemixRequestURL and read query parameters viasearchParams.
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
pageandpageSize, the service still applies defaults. This is intentional: services are reusable, so if another route callslistProductslater, it still gets consistent behavior. pagedefaults to1andpageSizedefaults to20, 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.
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/OFFSETpattern, withoffset = (page - 1) * pageSize. - Search uses
ILIKE(case-insensitive matching) and checks both SKU and name. - The SQL uses
$1,$2,$3placeholders 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 theProductdomain model.
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.methodand returns a405if it’s notPOST. That keeps behavior predictable. parseJson(request)is safer than callingrequest.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.
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
unknownbecause 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:
isStringensures required strings exist and aren’t empty.isIntensures numbers are true integers (not floats or NaN) and meet min bounds.isEnumensures currency is one of allowed values (USDfor now).
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
CONFLICTfailure with HTTP status409. 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.
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')ensuresstatusdefaults to"active"even if the caller doesn’t pass one.- The function returns a mapped
Productdomain 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.
- This is the “soft delete” approach: rather than deleting rows, you set
status='archived'. - Returning
nullwhen no row exists makes it easy for the service to return aNOT_FOUNDerror. - This function is the basis for the “D” (delete/archive) part of CRUD, and you’ll build the corresponding route behavior in later lessons.
In this lesson, you built the backbone of a real Products API:
GET /api/productsvalidates query params, delegates listing tolistProducts, and returns a consistentProduct[]success envelope.POST /api/productssafely parses JSON, validates input, callscreateProductService, and returns correct HTTP outcomes (400,409,201), always with consistent envelopes.- Validation is centralized in
validateCreateProductso 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.
