cf.completefrontend
My progress
SYSTEM DESIGN · #8

Design a pagination control

The numbered page control under search results, tables and listings. Drawing the buttons is easy; the real work is the page window algorithm that never jumps in width, keeping the page in the URL as real links, living with totals that are expensive or unknown, fetching pages without flashes or races, a compact mobile mode, and a control that screen reader and keyboard users can follow.

The question

Design the pagination control for our design system. It sits under product search results, under admin tables, and under the order history page. Some lists have 40 items, some have 3 million.

Walk me through which page numbers it shows, where the current page is stored, what you do when the backend can't tell you the total, how a page change loads, and how it works on a phone and with a screen reader.

Real-life examples: Google search result pages, GitHub issue and pull request lists, Amazon search results, MUI Pagination and its usePagination hook, Ant Design Pagination with its page size changer and quick jumper

Requirements: what to ask before drawing buttons

A pagination control is the row of links under a long list that splits it into pages: Previous, a few page numbers, and Next. You click 4 and you see items 61 to 80. Google search, GitHub issues and Amazon results all end with one.

It looks like a loop from 1 to the number of pages. That breaks the moment a list has 400 pages. A senior answer treats the control as the visible end of a bigger system: the URL, the API, the total count, the cache and the focus all move when the page changes.

Start with questions. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Where is it used?Search results, admin tables, order history.The control can't own the data. It gets the page and total from outside.
How big are the lists?40 items to 3 million rows.We can't render every number, and counting 3 million rows per request is slow.
Does the backend know the total?Search estimates. Orders can count, in 1 to 2 seconds. One feed is cursor based.Modes for exact, approximate, capped and unknown totals.
Shareable and indexed?Yes. Pages are crawled and links to page 3 get pasted in chat.The page lives in the URL, and every number is a real link.
Page size choice?In admin tables (10, 25, 50, 100).Where the size is stored, and where you land after changing it.
Devices and languages?60 percent phones at 360 pixels. Arabic and Hebrew too.A compact mode, and arrows that mirror right to left.

Functional requirements

  • Previous, Next, first and last page, the current page and its neighbors, with ellipses for gaps.
  • The page and page size live in the URL, so reload, Back, share and crawl all work.
  • Works with exact, approximate ("about 1,200"), capped ("1,000+") and unknown totals.
  • Jump to a page by number; pick a page size in tables.
  • On a change: keep the old page visible while loading, move to the top of the list, never show a stale page.
  • A compact phone layout: Previous, "Page 3 of 12", Next.

Non-functional requirements

  • Fast: a cached page change paints in under 100 ms; an uncached one shows data in about 500 ms on 4G.
  • Stable: the control never changes width while you page, so Next doesn't move under your finger.
  • Accessible: WCAG 2.2 AA, targets of at least 24 by 24 CSS pixels.
  • Small and crawlable: under 3 KB gzipped, every page reachable by a plain link in the server HTML.

Out of scope

  • Infinite feeds: see the infinite scroll guide. We only compare the patterns.
  • Sorting, filtering and the table itself (the data table guide). Here they only reset the page.
  • Writing the database queries: the pagination lesson covers offset and cursor queries.

Check your understanding · Exercise 1

Which answers from the interviewer change the design of the control itself, not just its styling? Select all that apply.

What makes it hard

Four facts drive every later decision; each gets its own chapter.

  1. The list is bigger than the control. 12,400 results at 20 per page is 620 pages, and the control has room for about 9 items. It must pick which numbers to show, at the same width on page 1 and page 620.
  2. The page is shared state. The router, the fetcher, the server renderer, crawlers and the person you send the link to all read it. In a useState inside the control, all of them lose it.
  3. The total is often a guess or a luxury. Counting every match can cost more than fetching the page, search engines only estimate, and cursor APIs don't know it at all.
  4. A page change replaces the whole list. Loading states, scroll position, focus and out-of-order responses all show up at once.

Here is the control we're building, in its two layouts.

Wireframe of a search results page and a phone. On the wide page: a summary reading Showing 41 to 60 of about 12,400, the result list of 20 items, then the control: Previous, pages 1 to 5 with 3 current, an ellipsis, 620, and Next, and below it a Go to page field. On the phone: the list, then Previous, 3 of 620, and Next. A note lists the rules: arrows disabled at the ends, the ellipsis hides 2 or more pages, and the current page has aria-current.shop.example/search?page=3Showing 41 to 60 of about 12,400result list, 20 items‹12345…620›Go to pageGojump fieldphonelist‹3 of 620›‹ › disabledat the ends… hides 2+ pages3: aria-current
The same control in two layouts. The wide one shows the page window and a jump field; the phone shows only Previous, where you are, and Next.

Wide screens get Previous, the page window (numbers and ellipses), Next, and a "Go to page" field, with a summary above the list. Phones get Previous, "Page 3 of 620" (which opens the jump field), and Next.

Numbered pages, Load more, or infinite scroll

First check that numbered pages are the right pattern. Interviewers often ask "why not infinite scroll?" and want a reason.

A buyer sees a pair of shoes "on page 3, near the bottom", opens a few tabs, and comes back an hour later. With numbered pages, page 3 is a link and the item is still roughly there. With an infinite feed she scrolls from the top again.

Decision How should a long list be split for the reader?

Numbered pages

Fixed size pages, each with its own URL like ?page=3.

Pros

  • Positions are findable again.
  • Pages can be shared and crawled.
  • The footer stays reachable.

Cons

  • A click and a wait per page.
  • The last page link needs a total, which can be expensive.

Load more button

A button under the list appends the next page.

Pros

  • The footer stays reachable.
  • Works with cursors. No total needed.

Cons

  • After 10 clicks, returning to item 150 means loading 200 items again.
  • Hard to share a position.

Infinite scroll

An IntersectionObserver on a sentinel near the end loads the next page.

Pros

  • No interruptions. Best for browsing feeds.

Cons

  • The footer is never reached.
  • Position is lost on reload.
  • An endless list for keyboard users.

What we pick: Numbered pages when people search, compare and come back: search results, admin tables, order history. Infinite scroll for feeds people browse with no goal (see the infinite scroll guide). Load more sits in between. Our three lists are all of the first kind.

Real products follow this split. Google search tried continuous scrolling and dropped it in June 2024: desktop went back to numbered pages, and phones got a "More results" button. GitHub, Amazon and nearly every admin tool use numbered pages; Instagram and TikTok use infinite feeds.

Three patterns side by side. Numbered pages: a list of 20, a row of page links, then the footer; findable and shareable. Load more: a list, a Load more button that appends the next page, then the footer, which stays reachable. Infinite scroll: a list that keeps growing as a sentinel comes into view, so the footer is never reached.Numbered pagesLoad moreInfinite scroll20 items‹123›20, then 40 itemsLoad more20, 40, 60 itemssentinelfindable, shareablefooter reachablefooter never reached
Numbered pages and Load more both keep the footer reachable. Only numbered pages give every position its own URL, which is why search results and admin tables use them.

Numbered pages also depend on the API. Jumping to page 40 needs an offset ("skip 780 rows, take 20"). A cursor API ("20 after this id") can only step forward and back. The pagination lesson explains both; the total count chapter shows the control for each.

Check your understanding · Exercise 2

For which lists would you choose numbered pages over infinite scroll? Select all that apply.

The page window: which numbers to show

The page window is the items between Previous and Next: page numbers and ellipses. The naive version renders range(1, totalPages): with 620 pages, a row that wraps across six lines.

The second naive version shows 5 numbers around the current page. Now there's no one-click way back to page 1, no hint how many pages exist, and the row changes length near the ends.

Decision Which items should the window show on a long list?

A sliding block of N pages

Pages current - 2 to current + 2.

Pros

  • Tiny code.

Cons

  • No first or last link.
  • No sign of the list's length.

First, last, siblings, and ellipses

boundaryCount pages at each end, siblingCount on each side of the current page, an ellipsis per gap, padded near the ends.

Pros

  • One click to either end.
  • A fixed item count, so the row never jumps.

Cons

  • Edge cases near the ends.
  • Far pages need a jump field.

Ellipsis as a jump button

Each ellipsis is a button that skips 5 pages (Ant Design does this).

Pros

  • Crosses big gaps without typing.

Cons

  • Users don't expect an ellipsis to be clickable.

What we pick: First, last, siblings and ellipses, with siblingCount and boundaryCount props defaulting to 1, as in MUI's usePagination. Ellipses stay plain text; long lists get a jump field.

The three rules

  1. Always show the ends. One click to page 1 or page 620.
  2. Never hide just one page. In "1 … 3" the ellipsis takes the space of the 2 it hides. Use an ellipsis only for 2 or more pages.
  3. Keep the item count stable. With the defaults, always 7 items once there are more than 7 pages. Near the start the block grows right: page 1 shows 1 2 3 4 5 … 620, not 1 2 … 620.
ts
type Item = number | "gap-start" | "gap-end";
const range = (a: number, b: number) =>
  b < a ? [] : Array.from({ length: b - a + 1 }, (_, i) => a + i);

export function pageWindow(page: number, total: number, siblings = 1, boundary = 1): Item[] {
  const slots = 2 * boundary + 2 * siblings + 3; // ends + block + two gaps
  if (total <= slots) return range(1, total);
  // The sibling block, pushed away from the ends so the count stays fixed.
  const start = Math.max(Math.min(page - siblings, total - boundary - 2 * siblings - 1), boundary + 2);
  const end = Math.min(Math.max(page + siblings, boundary + 2 * siblings + 2), total - boundary - 1);
  return [
    ...range(1, boundary),
    start > boundary + 2 ? "gap-start" : boundary + 1, // a gap of 1 is shown as the page
    ...range(start, end),
    end < total - boundary - 1 ? "gap-end" : total - boundary,
    ...range(total - boundary + 1, total),
  ];
}
The window as a pure function. Same rules as MUI's usePagination, written for this guide.

Walk through 10 pages with the defaults (7 slots). The 3-page sibling block is clamped between pages 3 and 8, leaving room for each end and its neighbor slot.

Current pageItemsWhat to notice
1 to 31 2 3 4 5 … 10The block is pushed right to fill 7 slots. Only the marker moves.
41 2 3 4 5 … 10A gap would hide only page 2, so 2 is shown, not "1 … 3".
5 and 61 … 4 5 6 … 10, 1 … 5 6 7 … 10Both gaps appear, each hiding 2 or more pages.
7 to 101 … 6 7 8 9 10The mirror of pages 1 to 4. On 10, Next is disabled.
total 61 2 3 4 5 6Fewer pages than slots: show all.
total 1nothingHide the control; keep the result summary.

Try it. The count changes only with the total, never while you page, and no ellipsis hides a single page.

siblingCount
boundaryCount
Algorithm
Page 1 of 20, siblingCount 1, boundaryCount 1, fixed count algorithm. The row reads: 1 2 3 4 5 … 20. 7 items now; across all pages the count ranges from 7 to 7. 0 ellipses hide just one page.‹12345…20›7 items nowall 20 pages: always 7 itemsNext never movesone-page gaps: 0every gap hides 2+ pages
Step through the pages: the count only changes when the total, siblingCount or boundaryCount changes, and no ellipsis ever hides a single page.

A stable count isn't the whole story: "9" is narrower than "620". Give every item the same min-width (40 pixels), use font-variant-numeric: tabular-nums, and always render Previous and Next, disabled at the ends. Now a user can click Next 5 times without moving the mouse.

The two counts are a trade-off between reach and width. siblingCount 2 shows 9 items and lets users step 2 pages either way with one click; it suits wide admin tables. boundaryCount 0 drops the ends and fits phones, but then page 1 is only reachable through Previous or the jump field. Defaults of 1 and 1 are the safe middle.

Think first

Why is pageWindow a pure function outside the component, and not code inside the render?

Check your understanding · Exercise 3

With 50 pages, siblingCount 1 and boundaryCount 1, which rows are correct? Select all that apply.

The page lives in the URL, and every page is a link

The naive control keeps useState(1) and renders button elements. Then a support agent sends a link to page 7 and the colleague lands on page 1. Reload loses the page, Back leaves the list, and crawlers never find page 2, because they don't click buttons.

Decision Where does the current page live, and what does clicking a number do?

Component state with buttons

useState; buttons call setPage.

Pros

  • Simplest. Fine inside a dialog.

Cons

  • Lost on reload and share.
  • No new tab, no crawling.

URL as the source of truth, with links

?page=3 in the query string; each number is an a href. The router handles plain clicks.

Pros

  • Reload, share, Back and new tabs work.
  • Crawlable, and works before JavaScript loads.

Cons

  • Links must carry the filters and sort.
  • Back steps through pages.

URL with buttons that call history.pushState

Buttons update the URL from code.

Pros

  • Reload and share work.

Cons

  • No new tab, no crawling, nothing before hydration.

What we pick: The URL is the source of truth and every page is a real link. The control reads page from props and never stores its own copy: the url-as-state lesson, applied to one number.

What happens on a click

Sequence of a click on page 3. The user clicks the link for 3. The Link pushes ?page=3 to the Router. The Router gives the Cache the key for page 3. The Cache sends GET page=3 to the API. The API replies 200 with rows and the total, and the Cache renders page 3 for the user and focus moves to the list heading. On the failure path the API replies with a 500 error, and the user keeps page 2's rows with a Retry message.UserLinkRouterCacheAPIclick 3push ?page=3key page 3GET page=3200 rows, totalpage 3, focusor 500page 2 + Retry
The link only changes the URL. The cache reacts to the new key, so Back, reload and a typed URL all take the same path. A Cmd or Ctrl click never reaches the router: the browser opens the href in a new tab.

A plain click is caught by the router's Link, which calls pushState. Cmd click, middle click and "open in new tab" skip the handler and use the href, so it must always be correct.

Each page click pushes a history entry, so Back returns to the previous page, the same as plain links on any website. A clamping redirect uses replace instead of push, so Back never lands on the invalid URL. The jump field and page size changes push, because users see them as navigation.

ts
export function pageHref(pathname: string, params: URLSearchParams, page: number) {
  const next = new URLSearchParams(params);
  if (page <= 1) next.delete("page");
  else next.set("page", String(page));
  const qs = next.toString();
  return qs ? `${pathname}?${qs}` : pathname;
}

// pageHref("/search", new URLSearchParams("q=shoes&sort=price&page=2"), 3)
// -> "/search?q=shoes&sort=price&page=3"
Building each link. Keep every other parameter; drop page=1 so the first page has one URL.

Search engines: canonical URLs, not rel=prev/next

  • Each page is its own canonical. Page 3's link rel="canonical" points to ?page=3. Pointing every page at page 1 marks the others as copies, so their items may never be indexed.
  • Google no longer uses rel="prev" and rel="next". It said so in 2019. They're harmless and other engines may read them, but crawlable a href links are what matter.
  • One URL per page, in the query string. pageHref drops page=1. Crawlers ignore everything after #, so #page=3 is invisible.
  • Keep sorted and filtered copies out of the index, as Google suggests, with a noindex robots meta tag or a robots.txt rule: ?sort=price&page=3 is the same items in another order.

Invalid pages: parse, clamp, redirect

The URL is user input: ?page=abc, ?page=-2, or an old link to ?page=40 after the list shrank to 12 pages. Parse strictly on the server, before rendering.

URLWhat we doWhy
?page=abc, 0, -2, 2.5Redirect to the bare URL (page 1).Garbage shouldn't create indexable URLs.
?page=40 of 12Temporary redirect (302 or 307) to ?page=12.The list may grow back, so not 301. The last real page beats an empty one.
A filter changes on page 4The new URL drops page.Page 4 of the old result means nothing now.

Check your understanding · Exercise 4

Which choices keep pagination shareable and crawlable? Select all that apply.

The total count problem

The window needs totalPages, so the naive API runs two queries per request: SELECT ... LIMIT 20 OFFSET 40 for the page and SELECT COUNT(*) ... WHERE status = 'shipped' for the total.

On the 3 million row orders table the page query uses an index and takes about 5 ms. The count is different. PostgreSQL keeps no stored count per filter and must check that each matching row is visible to the transaction, so it visits every one. With 2 million matches that is the 1 to 2 seconds from our requirements, on every click.

Search has the same problem at scale. Google labels its counts "About" because they are estimates. GitHub's search API serves only the first 1,000 results of any search. Elasticsearch counts exactly only up to 10,000 hits by default (its track_total_hits setting), then reports 10,000 as a lower bound.

Decision What total does the API return, and what does the control show?

Exact count

COUNT(*) with the same filters, maybe cached per filter set.

Pros

  • "Page 3 of 117" and a correct last page.

Cons

  • Cost grows with the number of matches.

Approximate count

The database planner's estimate (from EXPLAIN, or pg_class.reltuples for a whole table) or the search engine's: "about 12,400 results".

Pros

  • Almost free, still has a last page.

Cons

  • The last page may be wrong.
  • Bad for audit screens.

Capped count

Count up to a limit with LIMIT inside the count. Show "1,000+".

Pros

  • Bounded cost, exact for small results.

Cons

  • No last page past the cap.

No total (cursor or hasNext)

Fetch 21 rows to show 20; the 21st means hasNext. Or use nextCursor.

Pros

  • Cheapest; scales to any size.

Cons

  • Only Previous and Next.

What we pick: The API says what it knows and the control adapts: a total of kind exact, estimate or atLeast, or unknown with only a hasNext flag. Orders: exact for narrow filters, capped at 10,000 otherwise. Search: the engine's estimate. The cursor feed: Previous and Next.

Compare the four on the same list: what the control can show, and what the server pays.

Total
Matching rows
2,000,000 matching rows, 20 per page, page 3, total mode exact. The summary reads "2,000,000 results". The control shows ‹ 1 2 3 4 5 … 100,000 ›. Last page link: yes, page 100000. Server time in this model: 1205 ms, of which counting is 1200 ms.2,000,000 results‹12345…100,000›count: 1200 mspage query: 5 mstotal: 1205 mslast page link: 100,000exact and correct
An illustrative cost model: the page query takes 5 ms, and counting takes about 0.6 ms per 1,000 matching rows. Exact counts grow with the result; the other three stay small at any size, and each gives up something in the control.

When the API is cursor based

A cursor is an opaque token pointing at a row. "20 rows after this one" is fast at any depth and doesn't skip rows on inserts, but there is no page 40 without walking 39 pages. The reddit guide goes deeper into cursor listings.

  • The URL holds the cursor, ?after=eyJpZCI6ODgyMTN9: shareable, but not "page 5".
  • The control shows Previous and Next, maybe a client-counted "Page 5" (hidden on shared links).
  • Previous needs a before cursor from the API, so it works on a shared link.
  • A "First page" link is cheap. "Last page" needs a reverse sort.
ts
export type PageTotal =
  | { kind: "exact"; totalItems: number }          // "Page 3 of 117", last page link
  | { kind: "estimate"; totalItems: number }       // "About 12,400 results"
  | { kind: "atLeast"; totalItems: number }        // "10,000+ results", no last link
  | { kind: "unknown" };                           // Previous and Next only, from hasNext

export function lastPage(total: PageTotal, pageSize: number): number | null {
  if (total.kind === "exact" || total.kind === "estimate")
    return Math.max(1, Math.ceil(total.totalItems / pageSize));
  return null; // atLeast and unknown: the window has no right end
}
What the control receives, as a type. Each shape renders differently.

When lastPage is null, the window drops the last page and the end ellipsis means "more": 1 … 6 7 8 … Next, with "10,000+ results, refine your search".

Check your understanding · Exercise 5

The admin orders table has 3 million rows and an exact COUNT(*) takes 1.5 seconds with the widest filter. What helps? Select all that apply.

Changing pages: loading, scroll, focus and races

The naive page change clears the list, shows a spinner, fetches and renders. Four things go wrong:

  • The list collapses and the footer jumps (a layout shift).
  • The user is still scrolled to the bottom, far from the new rows.
  • Focus was on a link that may be gone, so keyboard users restart from the top.
  • Fast clicks can show page 3's rows under a control that says 4.

Keep the old page until the new one arrives

Decision What does the list show while the next page loads?

Clear and show a spinner

Unmount the rows; show a spinner.

Pros

  • Obviously loading.

Cons

  • Layout shift, and a flash even at 80 ms.

Skeleton rows

20 gray rows of the same height.

Pros

  • No shift. Good for the first load.

Cons

  • Hides content on every click.

Keep the previous page, dimmed

Page 3 stays, dimmed, with aria-busy="true" until page 4 arrives: TanStack Query's placeholderData: keepPreviousData.

Pros

  • No shift, no flash.

Cons

  • Stale rows must look stale.

What we pick: Skeletons for the first load, the previous page after that. The control marks page 4 current at once (the URL says 4), and a thin progress bar appears if the wait passes about 300 ms. See the server-state-libraries lesson.

tsx
const queryClient = useQueryClient();
const { data, isPlaceholderData, isError, refetch } = useQuery({
  queryKey: ["orders", { status, sort, page, pageSize }],
  queryFn: ({ signal }) => fetchOrders({ status, sort, page, pageSize }, signal),
  placeholderData: keepPreviousData, // show page 3 while page 4 loads
  staleTime: 30_000,
});

// Prefetch the next page once this one has arrived (not while page 3 stands in for it).
useEffect(() => {
  if (isPlaceholderData || !data?.hasNext) return;
  queryClient.prefetchQuery({
    queryKey: ["orders", { status, sort, page: page + 1, pageSize }],
    queryFn: ({ signal }) => fetchOrders({ status, sort, page: page + 1, pageSize }, signal),
  });
}, [data, isPlaceholderData, page, pageSize, sort, status, queryClient]);
TanStack Query v5. The key includes everything that changes the result.

Prefetching makes most changes instant. Most users go next, so fetch page N+1 once page N renders. For other numbers, prefetch on pointerenter or focus: a hover usually comes a few hundred milliseconds before the click. Don't prefetch every visible number: 7 extra requests per view.

The page request as a state machine

State machine for the list on a page change. Loading shows skeleton rows on the first visit and moves to showing on 200. Showing moves to switching when the URL page changes. Switching keeps the old rows dimmed; it moves back to showing when the reply for the current key arrives, and to error on failure. Error keeps the old rows with a Retry button, and Retry moves back to switching. Clicking again while switching changes the key, and the old reply is ignored.loadingskeleton rowsshowingpage N rowsswitchingold rows dimmederrorold rows, Retrylate reply foran old key:ignored200URL page changes200 for this keyfailRetryclick again: new key
Only the first load shows skeletons. After that, the list always has rows on screen: the current page, or the previous one dimmed while the next loads, or the previous one with an error and Retry.

Fast clicks and out-of-order responses

Three fast clicks request pages 4, 5 and 6. If page 5 arrives last, a naive setRows(response) shows it under a control that says 6. The fix, from the request-races lesson: only the response for the current key renders. A cache keyed by page does this for you.

Sequence of two fast clicks. The user clicks Next for page 4, and the Cache sends GET page=4. The user clicks Next again for page 5, and the Cache sends GET page=5. The API replies with page 5's rows first, and the Cache renders page 5. Then page 4's rows arrive late. The key is now page 5, so page 4 is cached but not shown.UserCacheAPINext: page 4GET page=4Next: page 5GET page=5page 5 rowsrender page 5page 4 rows, latekey is page 5 now:page 4 cached, not shown
Rendering is keyed by the page in the URL, so arrival order doesn't matter. Passing the query's signal to fetch also lets the cache abort the request nobody is waiting for.

Passing the query's signal to fetch also lets the cache abort requests nobody waits for, which saves server work when someone clicks through 6 pages to reach page 10. Aborting is an optimization, not the fix: abort does nothing once a body has been fully read, so the key check is still what keeps the screen correct.

Scroll and focus after the change

  • Scroll to the list, not the page top. listHeading.scrollIntoView({ block: "start" }), with scroll-margin-top for any sticky header. Skip it if the list top is visible.
  • Move focus to the list heading (tabindex="-1") with focus({ preventScroll: true }). Screen readers read "Orders, page 4", and Tab goes into the new rows (see the focus-management lesson).
  • One rule everywhere. Some teams keep focus on Next so users can skim with Enter. Either works; mixing them doesn't.
  • Respect reduced motion: smooth scrolling only without prefers-reduced-motion: reduce.
  • Update document.title to "Orders, page 4 of 117" so history entries differ.

Check your understanding · Exercise 6

A user on page 3 clicks Next twice quickly. Which behaviors are correct? Select all that apply.

Page size and jumping to a page

Changing the page size

Admin tables offer 10, 25, 50 and 100 rows. Where is the choice stored, and where do you land after changing it?

Store it in the URL (?size=50) so links show the same rows, and in localStorage per table for the next visit. The URL wins. Accept only the offered values, on client and server, so nobody requests ?size=100000.

Decision A user on page 7 at 25 per page (rows 151 to 175) switches to 100 per page. Where do they land?

Back to page 1

Drop page from the URL.

Pros

  • Simple. Many apps do it.

Cons

  • The user reading row 160 now sees rows 1 to 100.

Keep the first visible item

Math.floor((oldPage - 1) * oldSize / newSize) + 1: row 151 lands on page 2 (rows 101 to 200).

Pros

  • The row being read stays on screen.

Cons

  • No cursor support.

What we pick: Keep the first visible item when the size changes: users want more around what they're reading. Reset to page 1 when a filter, sort or search changes, because the old position means nothing.

Jumping to a page

With 117 pages, reaching page 63 takes about 30 clicks. Add a "Go to page" form past about 10 pages (Ant Design's quick jumper).

  • A real form with a labelled input type="text" inputmode="numeric": a number keyboard on phones, without the number input's scroll wheel quirks.
  • Submit navigates to the page link, through the same URL and clamping path as a click.
  • Validate digits from 1 to the last page; show "Enter a page from 1 to 117" via aria-describedby and keep focus in the field.
  • With capped or unknown totals, limit or hide it.

Check your understanding · Exercise 7

A user is on page 9 at 10 per page (rows 81 to 90) and picks 50 per page. Using "keep the first visible item", which page should open, and what else is right? Select all that apply.

Architecture, data model and component API

The rule that shapes everything: the control is a pure view of the URL and the total. It doesn't fetch, store the page, or know which list it's under, so one component serves every list.

Architecture of the pagination control. In the browser, the Router and URL holds page=3 and size=25 and passes the params down to useListQuery. useListQuery reads and writes the Query cache by key, and gives rows to the List view and page and total to the Pagination component, which uses usePagination inside. A link click in Pagination goes back up to the Router as a new URL. On the server, the Query cache misses go to the List API with GET, and the List API queries the Orders database.BrowserRouter + URL?page=3&size=25Query cacheone entry/pageuseListQuerykey, keep prevList viewrows, headingPaginationusePaginationparamskeyrowspage, totalclickServerList APIrows + totalOrders DBGETquery
Data flows one way: URL, query hook, views. The control never fetches and never stores the page; its only output is a link to a new URL.
PartResponsibilityWhy the boundary is here
Router and URLHolds and validates page, size, filters, sort.One truth for reload, share, Back and the server.
Server loaderFetches the first page, clamps or redirects, renders links.Crawlers and first paint get real content.
List query hookURL params to a query key; keeps previous, prefetches next.Caching and races handled once for every list.
usePaginationPure: page and total in, items out.Headless, so custom markup reuses the rules.
PaginationThe nav, links, current marker, compact layout.Styling and accessibility in one place.
List viewRows, busy state, the heading that takes focus.It knows where the list starts, so it owns scroll and focus.

How data flows

Data flow for loading page 3. The URL passes params to useListQuery, which sends a GET through the CDN; on a miss the CDN asks the List API, which queries the database for 20 rows and the count. The API's JSON reply is written to the Query cache. The cache gives rows to the List view and page and total to Pagination, and a click on one of its links writes a new href to the URL.URL?page=3useListQuerykey + fetchCDNcaches GETList APILIMIT 20PaginationlinksList viewrowsQuery cacheper pageDatabaseparamsGETmissSQLJSONrowspage, totalclick: new href
A loop with one entry point. For public lists like search, the CDN can cache popular pages because each one is a plain GET with the page in the query string. The only thing the control writes is the URL.

Read path: the URL gives page=4, the hook calls GET /api/orders?status=shipped&page=4&size=25, and the list and control render from the rows and total. Write path: a click changes the URL, nothing else.

Data model and API

ts
// URL (source of truth): /orders?status=shipped&sort=-date&page=4&size=25
type ListParams = { status?: string; sort: string; page: number; size: 10 | 25 | 50 | 100 };

// Server cache, keyed by ListParams: one entry per page.
type PageResponse<T> = {
  items: T[];
  page: number;          // the page the server actually served, after clamping
  size: number;
  total: PageTotal;      // exact, estimate, atLeast, or unknown
  hasNext: boolean;      // the server fetched size + 1 rows
  nextCursor?: string;   // only on cursor endpoints
};

// localStorage: the user's preferred size per table, e.g. "orders.pageSize" = "50".
Where each piece lives: URL, server cache, or local storage.

The response echoes page: if the client asked for 40 and got 12, it replaces the URL with ?page=12.

The component API

Decision How does the control tell the app that the page changed?

onChange(page) callback

Buttons; the parent updates state.

Pros

  • Fine for in-memory lists.

Cons

  • No href: no new tab, no crawling.

getHref(page) for links

Renders a href={getHref(n)}; the router handles clicks.

Pros

  • Real links, no state.

Cons

  • Needs a slot for the router's Link.

Headless usePagination plus renderItem

The hook returns items; the app renders each.

Pros

  • Any router, any markup.

Cons

  • Accessibility can be lost in custom markup.

What we pick: Ship all three: getHref by default, onChange for in-memory lists, the headless hook for rare custom designs. It's the controlled pattern from the component-api-design lesson.

tsx
type PaginationProps = {
  page: number;                         // controlled; from the URL
  total: PageTotal;                     // see chapter 6
  pageSize: number;
  getHref?: (page: number) => string;   // links (default)
  onChange?: (page: number) => void;    // buttons, for in-memory lists
  siblingCount?: number;                // default 1
  boundaryCount?: number;               // default 1
  linkComponent?: React.ElementType;    // the router's Link
  renderItem?: (item: PaginationItem) => React.ReactNode;
  "aria-label"?: string;                // default "Pagination"
};

Notice what's missing: no internal page state, no fetch function, no useEffect. The component renders the same HTML on the server and the client, so it hydrates without a mismatch, and it works with JavaScript disabled because every item is a link.

Check your understanding · Exercise 8

Which statements about this architecture are true? Select all that apply.

Mobile, accessibility and right-to-left

Compact mode on phones

The full row needs about 450 pixels; a 360 pixel phone has about 328 inside its margins. Shrinking links makes them hard to tap, and wrapping puts page 7 under page 2.

  • Compact layout: Previous, "Page 3 of 117", Next, with 44 pixel arrows. The label opens the jump field, or a native select under about 50 pages.
  • Switch with a container query, since the same table may sit in a narrow side panel. @container (max-width: 480px) swaps layouts; both are in the server HTML, so there's no flash.
  • Or drop siblings first: siblingCount 0 gives 1 … 5 … 117, 5 items.

Accessibility

html
<nav aria-label="Pagination">
  <ul>
    <li><a href="/orders?page=2" aria-label="Previous page" rel="prev">Previous</a></li>
    <li><a href="/orders" aria-label="Page 1">1</a></li>
    <li><a href="/orders?page=2" aria-label="Page 2">2</a></li>
    <li><a href="/orders?page=3" aria-label="Page 3" aria-current="page">3</a></li>
    <li><a href="/orders?page=4" aria-label="Page 4">4</a></li>
    <li><a href="/orders?page=5" aria-label="Page 5">5</a></li>
    <li aria-hidden="true">…</li>
    <li><a href="/orders?page=117" aria-label="Page 117, last page">117</a></li>
    <li><a href="/orders?page=4" aria-label="Next page" rel="next">Next</a></li>
  </ul>
</nav>
The markup for page 3 of 117. The visible text stays short; the accessible names are full.
  • A named landmark. nav with aria-label="Pagination" appears in the landmarks list. Two on a page need different labels.
  • aria-current="page" on the current link, plus a style beyond color. Screen readers announce it as the current page (see the aria-patterns lesson).
  • Names that stand alone. "4, link" means little in a links list; aria-label="Page 4" fixes it and still contains the visible text.
  • Disabled, not hidden, at the ends. On page 1, Previous stays in place as an a with no href, role="link" and aria-disabled="true": not focusable, still announced as unavailable.
  • Ellipses are decoration: aria-hidden="true".
  • Target size. WCAG 2.2 criterion 2.5.8 (AA) asks for at least 24 by 24 CSS pixels or enough spacing. We use 40, and 44 on touch.
  • Keyboard. Links are Tab stops opened with Enter. No arrow keys: this is a set of links, not a composite widget (see the keyboard-navigation lesson).

Designers often want the control above and below a long table. Two landmarks named "Pagination" confuse screen reader users, so label them "Pagination, top" and "Pagination, bottom", or show only the result summary on top. Both render from the same URL, so they can never disagree.

Right-to-left languages

In Arabic and Hebrew page 1 is on the right and Next points left. A flex row with logical properties (padding-inline) reverses on its own, but arrow icons don't: mirror them with transform: scaleX(-1) under :dir(rtl). Format totals with Intl.NumberFormat(locale) ("12.400" in German). The rtl-layouts lesson covers which icons to mirror.

The same pagination row in two directions. Left to right: Previous with an arrow pointing left, pages 1, 2, 3 current, an ellipsis, 9, and Next with an arrow pointing right. Right to left: the order is reversed, so Next sits on the left with an arrow pointing left, then 9, the ellipsis, 3 current, 2, 1, and Previous on the right with an arrow pointing right.dir=ltr‹ Prev123…9Next ›dir=rtl‹ Next9…321Prev ›order flipsby itselfarrows needa CSS flip
A flex row with logical properties reverses the order by itself. The arrow icons don't flip on their own: mirror them in CSS so Next always points the way the reader moves.

Check your understanding · Exercise 9

Which markup choices are right for the control? Select all that apply.

Running it in the interview

This question usually gets 30 minutes. It's easy, so the bar is depth: the window algorithm without hesitation, then how far past it you go.

MinutesWhat you doWhat to say or draw
0 to 4Scope.List sizes, whether the total is known, sharing, phones.
4 to 7Pattern.Why numbered pages: findable, shareable, a reachable footer.
7 to 14Page window.7 slots at page 1, 5 and n; the three rules; pageWindow.
14 to 18URL.Links not buttons, canonical per page, clamping.
18 to 23Totals and loading.Four total kinds; keep previous, prefetch, races, focus.
23 to 30API, accessibility.getHref vs onChange, aria-current, compact mode, tests.

Draw the wireframe first, then the architecture with the URL in the middle. Saying "the URL owns the page, not the control" early shortens every later answer.

Follow-up questions

Think first

New orders arrive while a user pages through with offset pagination. What goes wrong, and what would you do?

Think first

Someone requests ?page=50000 on the 3 million row table. Why is that slow even with an exact count, and how do you protect the database?

Check your understanding · Exercise 10

Which statements would strengthen an answer in the interview? Select all that apply.

Summary

  • The control is a pure view of the URL and the total. It never fetches and never stores its own page.
  • The window shows the ends, siblings around the current page, and an ellipsis only when it hides 2 or more pages. Its item count stays fixed while you page, so Next never moves.
  • Every page is a real link with its own canonical URL. Clamp invalid pages on the server and redirect to the last real page.
  • Totals come in four kinds: exact, estimate, at least, and unknown. Design the control for all four, because COUNT(*) on millions of rows is often the slowest part of the request.
  • On a page change keep the previous rows dimmed, render only the response for the current page, prefetch the next page, scroll to the list heading and move focus there.
  • Phones get a compact Previous, "Page 3 of 117", Next layout chosen by a container query.
  • Accessibility is a labelled nav, aria-current="page", full names like "Page 4", disabled ends that keep their place, and targets of at least 24 by 24 pixels.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next