Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/nexjs-app-router-pagination/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
UNIFORM_API_KEY=
UNIFORM_PROJECT_ID=
UNIFORM_PREVIEW_SECRET=hello-world

NEXT_PUBLIC_UNIFORM_INSIGHTS_API_KEY=
NEXT_PUBLIC_UNIFORM_PROJECT_ID=
NEXT_PUBLIC_UNIFORM_INSIGHTS_API_URL=https://analytics.uniform.global
8 changes: 8 additions & 0 deletions examples/nexjs-app-router-pagination/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
.next
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.vercel
208 changes: 208 additions & 0 deletions examples/nexjs-app-router-pagination/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Next.js App Router — Pagination patterns with Uniform

Two minimal demonstrations of paginating a list inside a Uniform composition rendered
with `@uniformdev/next-app-router` (the App Router / RSC SDK).

The two approaches sit at opposite ends of a tradeoff:

| Approach | Server-side re-render | Browser reload | Items in client RSC payload | Where pagination state lives | Slicing |
| --- | --- | --- | --- | --- | --- |
| **#1 — Datasource pagination via dynamic path segment** | Yes (route segment re-renders, fetch cached) | No (soft nav) | Only the current page | URL path (`/pagination-datasource/<page>`) | In the Uniform data resource (server-side, before any code runs) |
| **#2 — Pagination container with `UniformSlot` wrapper** | No (no round trip after first render) | No | All items rendered upfront | Client component `useState` | In the React component (client-side, after server sent everything) |

Both demos show **page-at-a-time** navigation with Prev / Next buttons; what
differs is *where* the slicing happens and consequently what's in the page
payload.

Pick the one that matches your bottleneck — both are demonstrated here as separate
commits so you can read the diff for each independently.

## Routes

After running the example you'll have two demo pages under the home locale:

- `/en/pagination-datasource/1` — Composition **#1**, real blog entries from a Uniform data resource, soft navigation.
- `/en/pagination-slot` — Composition **#2**, client-side reveal via `UniformSlot`.

The original starter home page at `/en` is left intact so you can compare a baseline
composition with the pagination demos.

## Getting started

1. **Prepare a Uniform project.** An empty project works — `uniform:push` will
create everything this example needs.

1. **Set your Uniform env vars** in `.env` — see `.env.example`. You need at
least `UNIFORM_API_KEY`, `UNIFORM_PROJECT_ID`, and the matching
`NEXT_PUBLIC_UNIFORM_PROJECT_ID`.

1. **Install dependencies.**

```bash
npm install
```

1. **Push the bundled content into your project.** The committed
`uniform-data/` folder is the source of truth — component definitions,
compositions, project map nodes, the `blogEntry` content type, the blog
data resources, and 10 demo entries. One command uploads all of it:

```bash
npm run uniform:push
```

If your project already has any of these entities, the CLI will print a
summary and you can resolve overlaps in the Uniform UI before running it
again.

1. **Publish the context manifest.** Required for personalization /
test / data-resource resolution at the edge.

```bash
npm run uniform:manifest
```

1. **Run the dev server.**

```bash
npm run dev
```

Open <http://localhost:3000/en/pagination-datasource> and
<http://localhost:3000/en/pagination-slot> to see the two approaches side by side.

> **Editing content in Uniform later?** Pull the changes back into git with
> `npm run uniform:pull` and commit the diff so the example stays
> reproducible.

> The pagination demos share a single set of Prev / Next controls
> ([`components/paginationControls.tsx`](./components/paginationControls.tsx)).
> Each approach wires those controls to a different state mechanism
> (router navigation vs. `useState`) but the rendered UI is identical.

## How each approach works

### Approach #1 — Datasource pagination via dynamic path segment

**Route:** `/en/pagination-datasource/<page>` (composition `01 - Datasource Pagination`).
A bare `/en/pagination-datasource` (no page number) is treated as page 1.
**Files:**
- [`components/paginatedList.tsx`](./components/paginatedList.tsx) — server component, renders the data-resource cards.
- [`components/routerPagination.tsx`](./components/routerPagination.tsx) — client component, wires `PaginationControls` to a router soft navigation.
- [`components/paginationControls.tsx`](./components/paginationControls.tsx) — generic Prev / Next UI, also used by Approach #2.
- [`middleware.ts`](./middleware.ts) — rewrites `/pagination-datasource/<page>` to `/pagination-datasource/<offset>` before the SDK sees it.
- [`lib/paginationDatasource.ts`](./lib/paginationDatasource.ts) — `PAGE_SIZE` (default 5) and the page↔offset helpers, shared between middleware and component so they can't drift.

All the actual content comes from Uniform. The composition has a `paginatedList` whose `cards` slot
holds a `$loop` bound to a `Query Blog Entry Content` data resource (`queryBlogEntry` data type),
with the `offset` variable bound to the project map node's `:offset` dynamic input. Page size is
the data resource's `limit` (5). **The code does not contain any sample data, mapping, or slicing —
the only computation in TypeScript is `(page - 1) * PAGE_SIZE`.**

How the data actually flows from the URL into the rendered cards:

1. Visitor browses to `/en/pagination-datasource/3` (or clicks Next from page 2). The browser URL
uses **page numbers** because that's what humans want to see and share. A bare path
`/en/pagination-datasource` (or `…/`) is treated as page 1 — the middleware fills in the
missing offset rather than letting Uniform 404.
2. The middleware's `rewriteRequestPath` transforms the path: `page = 3` → `offset = (3-1) * 5 = 10`,
producing `/en/pagination-datasource/10`. This rewrite is what Uniform sees — the browser URL
doesn't change.
3. The project map node `/:locale/pagination-datasource/:offset` matches, and Uniform exposes
`offset = "10"` as a dynamic input. The `queryBlogEntry` data resource's `offset` variable is
bound to that dynamic input, and `limit` is bound to `5`. The Route API resolves the data
resource against the underlying blog entry source and returns exactly the 5-item window.
4. The `$loop` expands its template card per entry server-side. `paginatedList` receives
`slots.cards` already containing 5 rendered card components with title/description bound to
blog fields — nothing left to do but `<UniformSlot slot={slots.cards} />`.
5. `paginatedList` also reads `context.dynamicInputs.offset` to compute the current page back
(`offset / PAGE_SIZE + 1`) and hands it to `PaginationControls`. Next is disabled when the
returned slot has fewer than `PAGE_SIZE` items — the partial page is unambiguously the last one.

The button labels themselves come from the composition. `paginatedList` exposes
`previousLabel` and `nextLabel` text parameters that are rendered through
`<UniformText>`, so authors can edit them inline in the Canvas editor (or change
them per locale) without touching code.

Key consequence: **only the current page ever crosses the wire, and the code knows nothing about
the entries**. The data resource handles all slicing on the Uniform side; the React component is
content-agnostic. The trade-off is that every Prev/Next click is a server round-trip — but the
upstream Route fetch is cached per resolved path (which includes the offset), so distinct pages
each become their own cache entry.

What you need on the Uniform side:

- A `paginatedList` component definition with a `cards` slot and four parameters: `heading`,
`previousLabel`, `nextLabel` (text, localizable).
- A dynamic project map node `/:locale/pagination-datasource/:offset` attached to the composition.
- The composition's data resource has `offset = ${offset}` and `limit = 5` in its variables (so
the dynamic input drives the slice).

> ⚠️ **Project gotcha**: this starter customises the middleware with `rewriteRequestPath`, so the
> page→offset translation has to live in *that* function — adding a generic Next.js rewrite in
> `next.config.ts` wouldn't run before the Uniform SDK sees the path. See
> [`middleware.ts`](./middleware.ts).

### Approach #2 — Pagination container with `UniformSlot` wrapper

**Route:** `/en/pagination-slot` (composition `02 - Pagination Container`).
**Files:** [`components/paginationContainer.tsx`](./components/paginationContainer.tsx),
[`components/card.tsx`](./components/card.tsx).

This is the App Router port of the well-known pages-router pattern where a
`PaginationContainer` wraps a slot, holds the current page in `useState`, and
slices the slot's children to show one page at a time. In the pages-router
SDK (`@uniformdev/canvas-react`) the slice happens inside
`<UniformSlot wrapperComponent={({ items }) => …} />`. The App Router SDK
(`@uniformdev/next-app-router`) doesn't have an `items`-style wrapper prop —
its `UniformSlot` is per-child only. The clean equivalent is to call
[`getUniformSlot({ slot })`](https://github.com/uniformdev/uniform/blob/main/packages/next-app-router/src/components/getUniformSlot.ts)
directly — it returns the items as an array of React nodes that you slice and
render however you want. That's what `paginationContainer.tsx` does.

```tsx
"use client";

const allItems = getUniformSlot({ slot: slots.cards }) ?? [];
const start = clampedPage * pageSize;
const visibleItems = allItems.slice(offset + start, offset + start + pageSize);
// …render visibleItems + Prev/Next buttons driven by a useState page index.
```

In the composition, `paginationContainer.cards` is filled by a `$loop` bound to
the **same `Query Blog Entry Content` data resource as Approach #1** — but here
the resource fetches a bigger window (50 items, no offset) and the container
slices them client-side. The container's parameters are `defaultLimit` (page
size), `defaultOffset`, plus `previousLabel` / `nextLabel` — the same label
parameters Approach #1 uses, so authors edit them the same way. Prev / Next
come from the shared
[`PaginationControls`](./components/paginationControls.tsx) — same component
the datasource demo uses — wired here to a `useState` page index instead of a
router. Paging is **purely a client state change** — no server round-trip,
instant page changes, no URL change. The user sees exactly one page at a
time; clicking Prev / Next moves to the adjacent page (not cumulative — old
items disappear when you move forward).

**The tradeoff.** Because `PaginationContainer` is a client component
(it needs `useState`), every element passed to it as part of the `slots` prop
gets serialized into the RSC payload. That means **all 50 cards are rendered
server-side and shipped to the browser on first load**, even though only one
page is visible. Paging here is a *visual* pagination, not a payload reduction.
You're trading payload size for zero-latency page changes.

When to prefer #2: small bounded slots (a handful to maybe a few dozen items)
where the simplicity of pure-client state is worth it, and where shipping the
hidden pages with the initial document is acceptable. When to prefer #1: the
underlying list is large enough that shipping the unrendered tail is actually
expensive.



## Important: Uniform Preview support

In order to support Uniform preview for Next.js 16 on Vercel, leave `middleware.ts`
named as such (do not rename to `proxy.ts`) and keep the runtime export:

```ts
export const runtime = "experimental-edge";
```
9 changes: 9 additions & 0 deletions examples/nexjs-app-router-pagination/app/api/preview/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {
createPreviewGETRouteHandler,
createPreviewPOSTRouteHandler,
createPreviewOPTIONSRouteHandler,
} from "@uniformdev/next-app-router/handler";

export const GET = createPreviewGETRouteHandler();
export const POST = createPreviewPOSTRouteHandler();
export const OPTIONS = createPreviewOPTIONSRouteHandler();
Binary file not shown.
6 changes: 6 additions & 0 deletions examples/nexjs-app-router-pagination/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@import "tailwindcss";

@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
}
35 changes: 35 additions & 0 deletions examples/nexjs-app-router-pagination/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";

import "./globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased bg-neutral-50 text-neutral-950`}
>
{children}
</body>
</html>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
PlaygroundParameters,
resolvePlaygroundRoute,
UniformPlayground,
} from "@uniformdev/next-app-router";

import { resolveComponent } from "@/components/resolveComponent";

export default async function PlaygroundPage({ params }: PlaygroundParameters) {
const { code } = await params;
return (
<UniformPlayground
code={code}
resolveRoute={resolvePlaygroundRoute}
resolveComponent={resolveComponent}
/>
);
}
31 changes: 31 additions & 0 deletions examples/nexjs-app-router-pagination/app/uniform/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
UniformComposition,
UniformPageParameters,
createUniformStaticParams,
resolveRouteFromCode,
} from "@uniformdev/next-app-router";
// TODO: Uncomment this to use resolveComponent with cache components support
// import { resolveRouteFromCode } from "@uniformdev/next-app-router/cache";
import { resolveComponent } from "@/components/resolveComponent";
import { CustomUniformClientContext } from "@/lib/uniform/CustomUniformClientContext";

export const generateStaticParams = async () => {
return createUniformStaticParams({
// Important: for localized sites, you need to add the locales to the paths
paths: ["/en"],
});
};

export default async function UniformPage(props: UniformPageParameters) {
// TODO: Uncomment this to use resolveComponent with cache components support
// 'use cache';
const { code } = await props.params;
return (
<UniformComposition
code={code}
resolveRoute={resolveRouteFromCode}
resolveComponent={resolveComponent}
clientContextComponent={CustomUniformClientContext}
/>
);
}
38 changes: 38 additions & 0 deletions examples/nexjs-app-router-pagination/components/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
ComponentParameter,
ComponentProps,
UniformText,
} from "@uniformdev/next-app-router/component";

export type CardProps = {
title?: ComponentParameter<string>;
description?: ComponentParameter<string>;
};

export const Card = ({
parameters: { title, description },
component,
}: ComponentProps<CardProps>) => {
return (
<div className="rounded border border-neutral-200 bg-white p-4 shadow-sm">
{title ? (
<UniformText
component={component}
parameter={title}
as="div"
className="font-medium"
placeholder="Card title"
/>
) : null}
{description ? (
<UniformText
component={component}
parameter={description}
as="p"
className="mt-1 text-sm text-neutral-600"
placeholder="Card description"
/>
) : null}
</div>
);
};
4 changes: 4 additions & 0 deletions examples/nexjs-app-router-pagination/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const FooterComponent = () => {
// TODO: implement your footer component here
return <></>;
};
4 changes: 4 additions & 0 deletions examples/nexjs-app-router-pagination/components/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const HeaderComponent = () => {
// TODO: implement your header component here
return <></>;
};
Loading