How to add pagination to a list of components in Uniform

Last updated: May 29, 2026

Paginating composition content in Next.js App Router

When a Uniform composition contains many child components grouped under the same parent — for example a long list of articles, product cards, or search results — showing all of them on the page at once may not be the right choice, for layout reasons, performance reasons, or both. This article describes two patterns for paginating those children and when to reach for each.

The steps and code samples below are based on the latest Uniform SDK 20 for Next.js App Router (@uniformdev/next-app-router).

A complete working example is available at examples/nexjs-app-router-pagination repo

At a glance

Option A — Server-side via dynamic project map

Option B — Client-side via slot wrapper

Page state lives in

URL path segment

React useState

Items rendered server-side to RSC

Just the current page

All children of the parent

Items in initial client payload

Just the current page

All children of the parent

Re-render scope on page change

Route segment (soft nav)

None

Full browser reload on page change

No

No

URL changes when paging

Yes

No (unless you add code)

Best when

Many children, expensive per-child rendering, or shareable URLs matter

Bounded number of children, instant page changes matter


Option A — Server-side pagination via dynamic project map

Concept. The composition is attached to a dynamic project map node like /list/:offset. The :offset value lands on the composition as a dynamicInput, which is bound to the offset variable of a content data resource that supports a windowed query (limit + offset). Uniform resolves the data resource against the underlying source — blogs, articles, products, search hits, etc. — and returns exactly the current page's items.

The Next.js side is responsible for just two things:

  1. Translate the visitor's page number to the offset Uniform expects, in middleware.

  2. Render Prev / Next buttons that update the URL.

The visitor-facing URL uses a 1-indexed page number (/list/3) rather than the raw offset, because that's more readable and more shareable.

Pros

  • The current page number is part of the URL — pages are linkable, shareable, refreshable to a specific position, and indexable by search engines. Option B can do this too, but only with extra code.

  • Only the current page is fetched, rendered, and serialised — the unrendered tail never crosses the wire.

  • Each page becomes its own Route fetch cache entry, so revisiting a page is near-free.

Cons

  • Every Prev / Next is a server round-trip (soft navigation — no document reload, but a new RSC payload).

  • Needs a small rewriteRequestPath adjustment in middleware to translate the visitor's page number to the offset Uniform expects.

  • Requires a content data resource that supports a windowed query (limit + offset or equivalent). The pattern is built around binding the URL parameter to the data resource's offset variable; without a paginable data source the URL parameter has nothing to drive. Use Option B for hand-authored or non-paginable content.

  • Since each page on the list is a separate web page, publishing new content will require cache revalidation for all or specific pagination URLs. For example, to make the new blog article appear in the paginated list, you will need to setup a webhook in the system where the blog articles are coming from, to revalidate your app and/or CDN cache for list/ and sub-paths.

Steps

In Uniform:

  1. Create a list-owner component (e.g. PaginatedList) with a single slot for the children.

  2. Create the composition and place PaginatedList in it.

  3. Add a content data resource on the composition that supports offset and limit. Bind offset = ${offset} (the dynamic input) and limit to a constant matching your page size. Use the data resource to populate the slot — for example with a $loop bound to the resource's items array and an item template inside.

  4. Create a placeholder project map node at the desired path (e.g. /list).

  5. Under it, create a dynamic project map node with the path segment :offset, attached to the composition.

In the Next.js app:

The custom code is intentionally minimal — just two pieces of logic, plus the usual resolveComponent registration.

  1. Translate page → offset in middleware. Treat a bare path as page 1.

    const offset = Math.max(0, (page - 1) * PAGE_SIZE);
  2. Prev / Next as a router.replace soft navigation. Read context.dynamicInputs.offset in the component to know which page is current.

    "use client";
    const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
    const goTo = (page: number) =>
      startTransition(() =>
        router.replace(`${basePath}/${page}`, { scroll: false })
      );

The list component itself is essentially <UniformSlot slot={slots.items} /> — the slot already contains the rendered current page. Register it in resolveComponent like any other component.

That is the entire code surface: an offset calculation in middleware and a router.replace in a button handler.

"Is this the last page?" comes from the slot itself: if it returned fewer than PAGE_SIZE items, the partial page is unambiguously the last one.


Option B — Client-side pagination via slot wrapper

Concept. A custom PaginationContainer component holds the current page index in useState. The container reads its slot's children as an array, slices to a window based on the page index, and renders only that window. Prev / Next mutate the state and the rendered window updates.

The important property: this works for any direct children of the container regardless of where they came from. They can be $loop-expanded items from a data resource (common when you have many blogs / articles / products), hand-authored individual components, or a mix of types. The pagination logic doesn't care — it just sees an array of children and a current page index.

Pros

  • Instant page changes — zero server round-trips after the initial render.

  • Works for any slot contents (mixed types, hand-authored, data-resource- driven — all the same).

  • One composition, one render path, no dynamic path segments — the simplest authoring model.

Cons

  • All children are rendered server-side and serialised into the initial RSC payload. Whether the user ever clicks Next or not, every child has already been shipped.

  • Current page is not in the URL by default — pages are not linkable / shareable / refreshable. You can extend the container to read and write the page from useSearchParams / router.replace, but that's additional code beyond the basic pattern.

  • The container itself must be a client component ("use client"), since it uses useState. That means its own render body can't call server-only APIs directly. Slot children are unaffected — Uniform renders them as RSC before they reach the container, so they can still be server components (and the reference example's Card is).

Steps

In Uniform:

  1. Create a PaginationContainer component with one slot for the children, plus parameters for defaultLimit (page size), defaultOffset (optional start position), and any author-controlled labels (e.g. previousLabelnextLabel).

  2. Add components to the slot. Anything that the slot's allowedComponents permits works — the pagination logic doesn't care what type they are or how they got there. Order matters: children are sliced into pages in the order they appear in the slot, so author them in the order you want visitors to see them.

  3. Create a regular project map node at the desired path, attached to the composition.

In the Next.js app:

All the pagination logic lives in the container. It reads the slot as an array, holds the current page in state, and renders only the current window. Prev / Next mutate the state; the rendered slot updates accordingly.

"use client";

import {
  ComponentParameter,
  ComponentProps,
  getUniformSlot,
} from "@uniformdev/next-app-router/component";
import { useState } from "react";

type Props = {
  defaultOffset?: ComponentParameter<number>;
  defaultLimit?: ComponentParameter<number>;
};

export const PaginationContainer = ({
  parameters: { defaultOffset, defaultLimit },
  slots,
}: ComponentProps<Props, "cards">) => {
  const offset = (defaultOffset?.value as number | undefined) ?? 0;
  const pageSize = (defaultLimit?.value as number | undefined) ?? 3;

  // Read the slot's children as an array so they can be sliced.const allItems = getUniformSlot({ slot: slots.cards }) ?? [];
  const pageable = allItems.slice(offset);
  const totalPages = Math.max(1, Math.ceil(pageable.length / pageSize));

  const [page, setPage] = useState(0);
  const clampedPage = Math.min(page, totalPages - 1);
  const start = clampedPage * pageSize;
  const visibleItems = pageable.slice(start, start + pageSize);

  return (
    <section>
      <div>{visibleItems}</div>

      <button
        type="button"
        onClick={() => setPage((p) => Math.max(0, p - 1))}
        disabled={clampedPage === 0}
      >
        ← Previous
      </button>

      <span>
        Page {clampedPage + 1} of {totalPages}
      </span>

      <button
        type="button"
        onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
        disabled={clampedPage === totalPages - 1}
      >
        Next →
      </button>
    </section>
  );
};

Register the container in resolveComponent. No middleware changes needed.


Sharing the controls between options

Both options expose the same UX — Prev / Next buttons with a page indicator — and you will likely want them to look identical. Extract a small presentational PaginationControls component with props like hasPrevhasNextonPrevonNext, and an indicator slot. Each option wires the callbacks to its own state mechanism (router navigation vs. useState), but the buttons themselves are shared. The reference example does exactly this — see components/paginationControls.tsx.


Scope and limitations

  • The reference example is Next.js App Router (@uniformdev/next-app-router, SDK 20). The patterns themselves are framework-agnostic — they work the same with @uniformdev/canvas-react (pages router) and other framework SDKs. Only the slot-access API names change. In pages router, UniformSlot's wrapperComponent={({ items }) => …} prop gives you the equivalent of getUniformSlot in one step.

  • Option A requires a content data resource that supports a windowed query (limit + offset or equivalent). The pattern works by binding the URL parameter to the data resource's pagination variable. If the parent's children are not driven by such a data resource, use Option B.

  • Option A uses a numeric dynamic path segment. For pagination keyed by opaque cursors (e.g. an API continuation token rather than an offset), use a query string parameter declared on the project map node instead of a dynamic path segment. The flow is otherwise identical — and make sure your middleware preserves url.search when rewriting paths so the value survives.

  • Tradeoff summary: Option A is the right default when the underlying list is large, when each child is expensive to render, or when shareable URLs matter. Option B is the right choice when the number of children is bounded enough that shipping them all is fine, and you want page changes to be instant and the authoring story to be as flat as possible.