This article was originally posted on the Cosmic.js Blog.
In order to get the most out of this article, you'll need a few things.
useState
hook, and likely already be using itIn our case, we're going to migrate an existing client-side pagination to the server. But why would we want to do this?
Client-side pagination, in itself, isn't an issue. There's nothing wrong with holding your state in the browser DOM and updating it when things change or need to change. Fundamentally, this is how a lot of us have handled pagination in React projects in the past.
But with React Server Components (RSC), we're able to shift this logic over to the server. This means a few things:
Let's look at a typical use case.
In our existing structure, we have two things. A call to Cosmic to fetch our data, and a state that handles knowing what page we're on.
Loading code...
So just to break this down if you're less familiar with the Cosmic SDK, here we're making an async request to the Cosmic SDK which allows us to fetch our data. We're passing in a single param, which is our optional page
param to get the current page number.
This page
number gets passed to our find
method's .skip()
. When combined with limit()
, skip()
ensures that we can take the current page number in, remove 1, and then multiply by the limit
number.
Let's break down this process:
Start with the current page number, for example, 1.
Subtract 1 from this number. In this case, 1 - 1 = 0.
Multiply the result (0) by our set limit, which is 9. This gives us 0 again.
We instruct our Cosmic data fetch to 'skip' by this result.
So, if we are on page one and use the 'skip' function, we will retrieve the first 9 posts. If we move to page 2, we take the number 2, subtract 1 to get 1, multiply by 9 to get 9, and then skip the first 9 posts. This will show us posts 10 through 18, effectively giving us "page 2".
In theory, this approach works. However, without client-side control, we'll only ever see the first 9 posts, which isn't very useful!
To handle this in our existing client-side State-powered world, we'll use useState
from React.
Loading code...
Let's break this down. We've got a state controller which holds an initial state of 1. We then have two functions which take in the current page value and then either remove 1 from the current page or add one. For the previousPage
function we have a ternary guard that ensures we don't end up with negative page numbers.
So to get this into our page, we declare our fetch request like so, and pass in our current page from our state:
Loading code...
And now, we can pass the pagination functions to a button component to update our state and handle the pagination transitions.
First thing's first, we need to get rid of the state, so let's delete our state declaration and remove the import dependency.
Now that we aren't using state in our component, where does our state live? Well, thanks to Next.js 13, it lives in the URL params.
To make this work, we'll request the searchParams
in the main Posts page. [Note: you can find out more about searchParams in the Next.js 13 Docs]
Loading code...
Next, we'll need to create the logic to validate the page received from our searchParams
is a typeof string
and then convert it to a number so the data fetch won't throw an error. This is all type safety stuff, so if you're not using Typescript, you can skip these bits (but I'd recommend you do use Typescript!)
Loading code...
This is similar to how we declared our state and set a default value of 1. It's a little more verbose, but it ensures our params are type safe.
You'll notice that searchParams
includes a .page
property on it. This is because searchParams
is a special type of parameter that has extra properties you can access. Thanks to it being a Next-specific parameter, Next has the type completions in place to make this obvious and accessible.
Now that we've handled the 'state' of our pagination using searchParams
, we need to handle passing our pagination to a component. We can remove our original function calls now too, we'll handle this logic in a special component. This is so we can move our client
logic to the lowest possible level in our stack if we need to. In our case, we don't need that, but its good practice in case you need to access the client at any point down the line.
Create a new component called Pagination
and put it where you like to keep them. Either alongside your pages, or in a separate directory.
In there, we'll import Link from "next/link"
to allow us to navigate. We're also going to need to pass in a couple of things, which is our posts
and our page
.
Loading code...
So now let's handle that logic.
Loading code...
Include whatever classes you want to for styles here, for now we'll keep it simple and just apply styles for remove pointer events and reducing the opacity if the page is either the initial page, or the final page. We've determined the final page by just measuring the length.
Now to get this working in our main page, we just need to import our new Pagination
component and assign the required props for posts
and page
.
Loading code...
And that's how simple it is.
Note: I've applied some default styles using Tailwind CSS to a few things and assumed you have a specific Cosmic bucket in place with the data you need.
Loading code...
So now you're able to pass all of your search logic into your URL parameters and not use any client-side JavaScript for it.
http://localhost:3000/?page=3
Try navigating to page 3, then copy the URL and paste it into an incognito window. It'll load up with the exact right data, and you'll be able to navigate from there back or forth. How cool is that!
By leveraging searchParams
instead of useState
, we introduce a significant advantage: the ability to persist and share specific application states through URLs. This means that a user can effortlessly share a URL containing certain parameters, and the recipient will see the exact same content or application state without any additional steps.
- For more information about how to use Cosmic in your application, visit our documentation
- Want to get started with Cosmic? Create an account for free.
Get an email whenever I publish a new thought.
import { createBucketClient } from "@cosmicjs/sdk";
const cosmic = createBucketClient({
bucketSlug: BUCKET_SLUG,
readKey: READ_KEY,
});
export async function getPosts(
page?: number
): Promise<Post[]> {
const data = await Promise.resolve(
cosmic.objects
.find({
type: 'posts',
})
.props([
'id,slug,title,metadata'
])
.limit(9)
.skip(((page ?? 1) - 1) * 9)
);
const posts: Post[] = await data.objects
return posts
}
const [page, setPage] = useState(1)
const previousPage = () => {
setPage(page !== 1 ? page - 1 : 1)
}
const nextPage = (newPage: number) => {
setPage(newPage)
}
const posts = await getPosts(page)
export default async function Posts({ searchParams }) {
// Your Posts content
};
const page = typeof searchParams.page === "string" ? +searchParams.page : 1;
export const Pagination = ({
posts,
page,
}: {
posts: Post[];
page: number;
}) => {
return (
// Our page code
)};
return (
<div className="mt-8 flex w-full justify-between">
<Link
className={
page === 1 && "pointer-events-none opacity-50",
}
href={`?page=${page - 1}`}
>
Previous
</Link>
<Link
className={
page === posts.length &&
"pointer-events-none opacity-50",
}
href={`?page=${page + 1}`}
>
Next
</Link>
</div>
);
<Pagination posts={posts} page={page} />
import { createBucketClient } from "@cosmicjs/sdk";
const cosmic = createBucketClient({
bucketSlug: BUCKET_SLUG,
readKey: READ_KEY,
});
type Post = {
title: string
metadata: {
subtitle: string
}
}
export async function getPosts(
page?: number
): Promise<Post[]> {
const data = await Promise.resolve(
cosmic.objects
.find({
type: 'posts',
})
.props([
'id,slug,title,metadata',
preview,
])
.limit(9)
.skip(((page ?? 1) - 1) * 9)
);
const posts: Post[] = await data.objects
return posts
}
export default async function Posts({ searchParams }) {
const page = typeof searchParams.page === "string" ? +searchParams.page : 1;
const posts = await getPosts(page);
return (
<div className="mx-auto w-full">
<div className="flex w-full items-center justify-between">
<header>Posts</header>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-3 md:gap-16">
{posts.map((post: Post, idx: number) => (
return (
<Link href={post.href}>
<div
className="group flex h-full flex-col
justify-between space-y-2 rounded-xl
transition-all ease-in-out"
>
<div className="flex h-full flex-col">
<div
className="flex w-full
items-start justify-between gap-2"
>
<header
className="mr-2 block pb-2 text-lg
font-medium leading-tight text-zinc-700
group-hover:underline
group-hover:decoration-zinc-500
group-hover:decoration-2
group-hover:underline-offset-4
dark:text-zinc-200">
{post.title}
</header>
<ArrowRightIcon
className="h-4 w-4 flex-shrink-0
text-zinc-500 dark:text-zinc-400" />
</div>
<span className="block pb-2
text-zinc-500 dark:text-zinc-400"
>
{post.subtitle}
</span>
</div>
</div>
</Link>
)
)};
<Pagination posts={posts} page={page} />
</div>
);
}