Ditching State for searchParams: The Future of Next 13
This article was originally posted on the Cosmic.js Blog.
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 utilising 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"; 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 }
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
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.
const [page, setPage] = useState(1) const previousPage = () => { setPage(page !== 1 ? page - 1 : 1) } const nextPage = (newPage: number) => { setPage(newPage) }
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:
const posts = await getPosts(page)
And now, we can pass the pagination functions to a button component to update our state and handle the pagination transitions.
Switching to searchParams
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]
export default async function Posts({ searchParams }) { // Your Posts content };
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!)
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
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
.
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 using Tailwind CSS 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: 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> ); }
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.
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.