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 |
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:
Translate the visitor's page number to the offset Uniform expects, in middleware.
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
rewriteRequestPathadjustment 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+offsetor 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:
Create a list-owner component (e.g.
PaginatedList) with a single slot for the children.Create the composition and place
PaginatedListin it.Add a content data resource on the composition that supports
offsetandlimit. Bindoffset = ${offset}(the dynamic input) andlimitto a constant matching your page size. Use the data resource to populate the slot — for example with a$loopbound to the resource's items array and an item template inside.Create a placeholder project map node at the desired path (e.g.
/list).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.
Translate page → offset in middleware. Treat a bare path as page 1.
const offset = Math.max(0, (page - 1) * PAGE_SIZE);Prev / Next as a
router.replacesoft navigation. Readcontext.dynamicInputs.offsetin 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 usesuseState. 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'sCardis).
Steps
In Uniform:
Create a
PaginationContainercomponent with one slot for the children, plus parameters fordefaultLimit(page size),defaultOffset(optional start position), and any author-controlled labels (e.g.previousLabel,nextLabel).Add components to the slot. Anything that the slot's
allowedComponentspermits 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.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 hasPrev, hasNext, onPrev, onNext, 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'swrapperComponent={({ items }) => …}prop gives you the equivalent ofgetUniformSlotin one step.Option A requires a content data resource that supports a windowed query (
limit+offsetor 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.searchwhen 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.