Ditching State for searchParams: The Future of Next.js 13

KarlKarl
8 min read

Prerequisites

In order to get the most out of this article, you'll need a few things:

  1. To be using Next.js 13 or later.

  2. To be utilizing the new App Router project structure.

  3. To be familiar with React's useState hook, and likely already be using it.

  4. To be aware of the underlying fundamentals of client and server components in React 18 and Next 13.

  5. 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:

  1. We're shipping less client-side JavaScript which is great for page load times, and SEO.

  2. We're able to store state in the URL params rather than in a state object, making these URL states shareable with others.

  3. 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:

  1. Start with the current page number, for example, 1.

  2. Subtract 1 from this number. In this case, 1 - 1 = 0.

  3. Multiply the result (0) by our set limit, which is 9. This gives us 0 again.

  4. 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 things 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 are 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 for styles here, for now, we'll keep it simple and just apply styles for removing 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 on 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.

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!

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.

10
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.