Welcome to the final course in the path, Stabilizing Express APIs: Pagination, Versioning, and Testing! Your API already supports authentication, role-based authorization, customer ownership rules, nested comments, and explicit ticket workflows.
In this lesson, you’ll make the API’s successful responses more predictable and its collection endpoint more scalable. You’ll add validated pagination, expand filtering and sorting, parameterize LIMIT and OFFSET, and return every successful resource through a consistent { data, meta } envelope.
Previously, different successful routes returned different top-level shapes. Ticket collections returned raw arrays, detail routes returned raw objects, and collection totals appeared only in the X-Total-Count header. Those responses worked, but clients had to handle each endpoint differently. The new response helper creates one stable structure for collections, resources, comments, statistics, and workflow results.
Understanding the Client-Visible Migration
Earlier, the ticket collection returned a raw array:
Successful deletion remains the exception: 204 No Content still has no response body.
Why Collections Need Pagination
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
As the number of tickets grows, returning every matching row becomes increasingly expensive. Large unbounded responses consume more database time, server memory, network bandwidth, and client rendering work.
Pagination divides the filtered result into manageable pages:
The repository still needs the total number of filtered rows so clients can calculate navigation. PostgreSQL’s window count allows the API to retrieve the current page and the full filtered total in one query.
Expanding the Collection Domain Contract
The collection contract in src/domain/ticket.ts now includes more sort options, an assignee filter, and required pagination values.
export type TicketSortField = | "createdAt" | "updatedAt" | "priority" | "status";export type SortOrder = "asc" | "desc";
createdAt sorts by when the ticket was opened.
updatedAt sorts by the most recent mutation, including updates and workflow transitions.
priority uses the ticket’s logical low-to-high ranking rather than alphabetical order.
status uses the workflow sequence open, in_progress, and closed.
The repository options combine filters, sorting, and pagination:
requesterId remains a server-controlled access filter for customers.
assigneeId is a client-facing optional filter that can narrow the collection to tickets assigned to a specific user.
page and pageSize are required repository inputs. The route always supplies normalized values because Zod provides defaults.
The repository does not need to parse strings or decide fallback pagination behavior.
Converting URL Strings into Numbers
Express query parameters begin as URL-derived values. A request such as:
GET /api/tickets?page=2&pageSize=4
does not naturally provide JavaScript numbers. The query schema in src/schemas/ticket-schemas.ts uses Zod coercion to convert valid numeric strings before repository code runs.
The same schema validates the expanded collection controls.
assigneeId: z.string().trim().min(1).optional(),sortBy: z .enum(["createdAt", "updatedAt", "priority", "status"]) .default("createdAt"),order: z.enum(["asc", "desc"]).default("desc"),
assigneeId is trimmed and must remain non-empty when supplied.
sortBy is restricted to fields implemented by the repository’s trusted expression map.
order remains limited to asc and desc.
Unsupported values fail before PostgreSQL runs:
GET /api/tickets?page=0GET /api/tickets?pageSize=51GET /api/tickets?page=abcGET /api/tickets?sortBy=title
Each request enters the centralized error pipeline and returns 400 VALIDATION_ERROR.
Building Parameterized Filter Conditions
Constructing the Optional WHERE Clause
Mapping Sort Fields to Trusted SQL
PostgreSQL parameters can represent data values, but they cannot safely replace column names, complete expressions, or SQL keywords. Sorting therefore comes from a fixed server-owned map.
const sortExpressions: Record< TicketListOptions["sortBy"], string> = { createdAt: "created_at", updatedAt: "updated_at", priority: "CASE priority " + "WHEN 'low' THEN 1 " + "WHEN 'medium' THEN 2 " + "WHEN 'high' THEN 3 END", status: "CASE status " + "WHEN 'open' THEN 1 " + "WHEN 'in_progress' THEN 2 " + "WHEN 'closed' THEN 3 END",};
Timestamp fields map directly to their PostgreSQL columns.
Priority maps to a fixed CASE expression so ascending order means low, medium, then high.
Status maps to the workflow sequence rather than alphabetical order.
The request’s raw sortBy string is never inserted directly into SQL. Zod restricts it to keys supported by the map.
The direction also comes from a trusted branch:
const direction = options.order === "asc" ? "ASC" : "DESC";
Only the two known SQL keywords can be produced.
Keeping Pagination Stable
The final ORDER BY includes a deterministic tie-breaker:
ORDER BY <selected expression> <direction>, id ASC
The id ASC portion matters when several tickets have the same selected sort value.
Without a stable tie-breaker, rows with equal priorities or timestamps could appear in a different relative order between requests. That could cause a row to move between pages or appear more than once as the client paginates.
The ticket ID gives PostgreSQL a final predictable ordering rule.
Parameterizing LIMIT and OFFSET
Counting Before Paginating
Understanding an Empty Page
COUNT(*) OVER() is available only on returned rows. When an offset moves beyond the final matching row, PostgreSQL returns no rows, so this implementation falls back to total: 0.
For normal navigation, clients use totalPages from an earlier valid page and avoid requesting pages beyond that range. The project’s current contract does not reject an out-of-range positive page number.
The important guarantee is that valid populated pages return both their current rows and the complete filtered total in one database round trip.
data contains only the current page’s ticket array.
page and pageSize describe the requested page.
total describes all rows matching the filters before pagination.
totalPages is calculated from the total and page size.
The old X-Total-Count-only contract is no longer the collection’s primary metadata mechanism. Clients can read all pagination information from the response body.
The array should also appear under data with the same outer structure.
You can inspect statistics and a workflow action to verify that those successful responses follow the same contract.
Reading the New Contract as a Client
Clients should now follow these rules:
Successful resource:response.dataSuccessful collection:response.dataresponse.meta.pageresponse.meta.pageSizeresponse.meta.totalresponse.meta.totalPagesRequest diagnostics:response.meta.requestIdresponse.meta.durationMsFailed request:response.errorresponse.metaSuccessful deletion:no response body
This consistency reduces endpoint-specific parsing and prepares the API for versioning and integration testing.
Recap
You’ve started stabilizing the API by adding bounded pagination and one standard success envelope.
Zod now converts URL strings into validated numeric page and pageSize values, supports assignee filtering, and restricts sorting to trusted fields. The repository combines parameterized filters, fixed sort expressions, stable tie-breaking, parameterized LIMIT and OFFSET, and COUNT(*) OVER() in one PostgreSQL query.
The reusable sendSuccess() helper wraps ticket arrays, ticket resources, comments, statistics, creation results, updates, and workflow actions in { data, meta }. Collection metadata now includes page information and totals directly in the response body, while deletion correctly remains a bodyless 204.
With a predictable collection contract and consistent successful responses, the API is ready for versioned routing, readiness checks, and automated integration testing.
The calculated offset becomes the value used by OFFSET.
Both values have already passed Zod’s integer and range validation.
Neither number is interpolated directly into the SQL statement.
If the earlier filters used $1 through $3, pagination automatically continues with $4 and $5.
The repository first counts the filtered collection, then retrieves the requested page. Keeping the count separate ensures an out-of-range positive page still reports the real filtered total even when the page itself contains no rows.
TypeScript
const countResult = await query<TicketCountRow>( `SELECT COUNT(*)::int AS total FROM tickets ${whereClause}`, values,);const total = countResult.rows[0]?.total ?? 0;const pageValues = [...values];pageValues.push(options.pageSize);const limitPlaceholder = `$${pageValues.length}`;pageValues.push((options.page - 1) * options.pageSize);const offsetPlaceholder = `$${pageValues.length}`;const result = await query<TicketRow>( `SELECT ${ticketColumns} FROM tickets ${whereClause} ORDER BY ${sortExpressions[options.sortBy]} ${direction}, id ASC LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`, pageValues,);
WHERE filters the permitted collection before pagination.
COUNT(*) OVER() calculates the total matching rows without collapsing them into one aggregate result.
LIMIT controls the maximum number of current-page rows.
OFFSET skips rows belonging to earlier pages.
The selected sort expression and direction come only from trusted application code.
All request-derived data remains in values.
The repository then separates the page data from the repeated window count: