Ditching State for searchParams: The Future of Next 13
Prerequisites
In order to get the most out of this article, you'll need a few things:
To be using Next.js 13 or later.
To be utilizing the new App Router project structure.
To be familiar with React's
useState
hook, and likely already be using it.To be aware of the underlying fundamentals of client and server components in React 18 and Next 13.
To be using Cosmic as your CMS of choice (although these approaches will work for other data stores such as databases or other CMSs).
Getting started
In 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:
We're shipping less client-side JavaScript which is great for page load times, and SEO.
We're able to store state in the URL params rather than in a state object, making these URL states shareable with others.
We keep all of our app logic on the server, so re-fetching data is a server-server interaction rather than client-server.
Let's look at a typical use case.
useState-powered Pagination
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.
Our Cosmic content
import { createBucketClient } from "@cosmicjs/sdk";
// Connect to your Bucket. Find the keys in Bucket > Settings > API keys
const cosmic = createBucketClient({
bucketSlug: BUCKET_SLUG,
readKey: BUCKET_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
}
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".
Our State
So, this in theory works. But without any client-side control, we're just going to always see the first 8 posts forever. And that's not useful!
To handle this in our existing client-side State-powered world, we'll use useState
from React.
We're going to keep things nice and simple.
const [page, setPage] = useState(1)
const previousPage = () => {
setPage(page !== 1 ? page - 1 : 1)
}
const nextPage = (newPage: number) => {
setPage(newPage)
}
Let's break this down now. We've got a simple state controller which holds an initial state of 1. We then have two simple 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 little 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:
const posts = await getPosts(page)
And now, we can pass the pagination functions to a simple button to update our state and handle the pagination transitions.
Switching to searchParams
First thing's first, we need to lose the state. So let's delete our state declaration and remove the import dependency.
So now we don't hold state, where does our state live? Well, thanks to Next 13, it lives in the URL params. [Note: you can find out more about searchParams in the Next.js 13 Docs]
To make this work, we'll request the searchParams
in the main Posts page.
export default async function Posts({ searchParams }) {
// Your Posts content
};
Next, we'll need to create a little 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 complain. 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!)
const page = typeof searchParams.page === "string" ? +searchParams.page : 1;
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.
Pagination
Cool, so now that's done, we need to handle passing our pagination. 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 it's 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
.
export const Pagination = ({
posts,
page,
}: {
posts: Post[];
page: number;
}) => {
return (
// Our page code
)
};
So now let's handle that logic.
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>
);
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
.
<Pagination posts={posts} page={page} />
And that's how simple it is.
Putting it altogether
Note: I've applied some default styles to a few things and assumed you have a specific Cosmic bucket in place with the data you need.
import { createBucketClient } from "@cosmicjs/sdk";
const cosmic = createBucketClient({
bucketSlug: BUCKET_SLUG,
readKey: BUCKET_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'
])
.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) => (
<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>
)
)};
</div>
<Pagination posts={posts} page={page} />
</div>
);
}
Concluding things
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.
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!
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.
Subscribe to my newsletter
Read articles from Karl directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Karl
Karl
Design Engineer by day at DuckDuckGo and by night at Cosmic. Helping startups from time-to-time with 0 → 1 products. Part of the “Cracked Photoshop & MySpace” generation. Listener and creator of heavy music.