Implementación de paginación en Nextjs

힘센캥거루
2025년 1월 5일(수정됨)
69
nextjs
Implementación de paginación en Nextjs-1

Quería hacer paginación, busqué artículos, pero eran pocos y no me convencieron.

La mayoría de los artículos encontrados en blogs eran ejemplos que usaban useState y API.

Aunque estos métodos son fáciles de implementar, no aprovechan las ventajas del renderizado del lado del servidor.

Hoy vamos a intentar implementar la paginación en Nextjs con renderizado del lado del servidor (SSR).

Como siempre, el artículo que consulté fue el documento oficial.

1. Idea de la paginación SSR

La clave de este artículo es hacer que el componente de página reciba cadenas de consulta como entradas.

Se dice que usar cadenas de consulta tiene las siguientes ventajas:

  • Marcadores y compartir URL: Los parámetros de búsqueda están en la barra de direcciones, por lo que se pueden compartir o marcar.

  • Renderizado del lado del servidor: Se puede usar directamente en el servidor para renderizar el estado inicial.

  • Análisis y seguimiento: Es posible rastrear el comportamiento del usuario sin código adicional del lado del cliente.

Ahora, veamos el código.

En props se puede analizar la cadena de consulta con searchParams.

export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query || "";
  const currentPage = Number(searchParams?.page) || 1;

  return (
    <div>
      <p>searchParams</p>
      <p>currentPage</p>
    </div>
  );
}

Si accedo con '/test?page=1&query="힘센캥거루"', se puede ver que la consulta se muestra dentro del div.

Ahora solo queda cocinar esta consulta de manera deliciosa.

2. Implementación de la barra de búsqueda

Tomamos el componente de Búsqueda directamente del documento oficial.

Por ahora, solo he configurado para que cuando se ingrese una palabra en el campo de búsqueda, se imprima en el registro de la consola del navegador.

"use client";

import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";

export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) {
    console.log(term);
  }

  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border 
        border-gray-200 py-[9px] pl-10 text-sm outline-2
         placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

Después de esto, coloqué el siguiente código en el componente de layout.

Se puede colocar el componente donde sea, no importa.

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="mx-auto max-w-screen-xl w-full prose">
      <div className="flex justify-between">
        <div>
          <h1>Lista</h1>
        </div>
        <div className="w-80">
          <Search placeholder="Ingrese una palabra clave" /> // Caja de búsqueda
        </div>
      </div>
      <div className="mb-8">
        <TagGroup path="/category" tags={tagsArray} />
      </div>
      <div>{children}</div>
    </div>
  );
}

Ahora, ingrese una palabra en el campo de búsqueda y observe el registro de la consola.

Podrá ver que se imprime en el registro cada vez que se produce una entrada.

Implementación de paginación en Nextjs-2

Ahora invoque useSearchParams, usePathname, useRouter y cree un objeto.

Consulte el siguiente código y comentarios.

"use client";

import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";

export default function Search() {
  const searchParams = useSearchParams();
  // Llamada a useSearchParams
  const pathname = usePathname();
  // Uso de URL existente con usePathname
  const { replace } = useRouter();
  // Reemplazo de la dirección existente con replace de useRouter
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    // Creación de la cadena de consulta con la función incorporada URLSearchParams
    if (term) {
      params.set("query", term); // Reasignación si hay un valor en input
    } else {
      params.delete("query"); // Eliminación si no hay valor
    }
    replace(`${pathname}?${params.toString()}`);
    // Reemplazo de la dirección en la barra de direcciones sumando la URL original a la cadena de consulta creada
  }
  // ...
}

Al escribir en el campo de búsqueda y ejecutar el código, verá que la dirección cambia.

Implementación de paginación en Nextjs-3

Sin embargo, si ingresamos directamente en la barra de direcciones '/category?query=이게되는교', verá que no hay nada en el campo de entrada.

Para sincronizar el campo de entrada de búsqueda con la cadena de consulta en la barra de direcciones, agregue el siguiente código al input.

Después de ingresar el valor por defecto, al acceder con la URL de consulta, el valor del input está sincronizado.

<input
  // ...
  defaultValue={searchParams.get("query")?.toString()}
  // ...
/>
Implementación de paginación en Nextjs-4

Pero hay un problema aquí.

Ocurre un cambio cada vez que se introduce una consonante o vocal.

Vamos a modificar para que se ejecute 300ms después de que ocurra la última entrada en el campo de búsqueda.

A esto se le llama debounce, y es bueno para la salud mental instalar una librería.

yarn add use-debounce

Y modifique el código de la siguiente manera.

Después de invocar debounce, envuelva la función handleSearch con useDebouncedCallback.

Al final, agregue 300, y la dirección se cambiará 300ms después de que se produzca la última entrada.

// ...
import { useDebouncedCallback } from "use-debounce";

const handleSearch = useDebouncedCallback((term) => {
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set("query", term);
  } else {
    params.delete("query");
  }
  replace(`${pathname}?${params.toString()}`);
}, 300);

Ahora, en el lado del servidor, solo se necesita analizar la consulta y devolver la información necesaria.

3. Implementación de la función de búsqueda del lado del servidor

El lado del servidor probablemente difiera para cada persona.

En mi caso, actualmente guardo y uso los metadatos de cada mdx en formato JSON.

Entonces, usando la consulta en la barra de direcciones, puede filtrar y devolver al componente.

import getPostCategory from "@/utils/getPostCategory";
import CategoryPage from "@/components/blog/CategoryPage";
import { postCardObject } from "@/types/allTypes"; // Este es un tipo que definí personalmente.

export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query?.toLowerCase() || "";
  const currentPage = Number(searchParams?.page) || 1;
  // Análisis de la consulta. Paginación aún no implementada.
  // Convertir todo a minúsculas para evitar distinguir entre mayúsculas y minúsculas.

  let posts: postCardObject[] = await getPostCategory();
  // Se cargan todos los datos de los posts.

  if (query) {
    posts = posts.filter((post) => {
      const title = post.data.title.toLowerCase();
      return title.includes(query);
    });
  }
  // Si hay una consulta, se aplica un filtro para reasignar posts.
  // Más tarde, se colocará dentro de getPostCategory para que sea más limpio, pasando la consulta como parámetro.

  return (
    <>
      <CategoryPage postCategorys={posts} />
    </>
  ); // Y se renderiza como una lista de posts.
}
Implementación de paginación en Nextjs-5

Probando, vemos que los posts se filtran según la palabra clave de búsqueda.

Con esto, la funcionalidad de búsqueda se ha completado.

4. Implementación de paginación

La paginación es igual que la implementación del campo de búsqueda.

Solo hay que pasar la cadena de consulta de la página clicada con useSearchParams, useRouter y usePathname.

Hay algunas diferencias.

El número de posts que se mostrarán en una página y el total de páginas deben preasignarse al componente.

export default async function getPostCategory(
  perPagePosts: number,
  page = 1,
  tag?: string
) {
  // ...
  const totalPage = Math.ceil(postCategorys.length / perPagePosts);
  postCategorys = postCategorys.slice(
    0 + perPagePosts * (page - 1),
    perPagePosts + perPagePosts * (page - 1)
  );
  // ...
  return { postCategorys, totalPage };
}

He configurado para que la función que carga los datos de los posts devuelva el total de páginas mientras corta 6 posts por página.

Y se crean páginas dividiendo el número total de páginas por 10.

Probemos creando un Link como ejemplo.

export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  // ...
  return (
    <div>
      <CategoryPage postCategorys={postCategorys} />
      <div>
        {Array.from({ length: totalPage }, (_, i) => i + 1).map((i) => (
          <Link
            key={i}
            className={`mx-3 bg-gray-200 ${
              currentPage === i ? "bg-gray-700 text-white" : ""
            }`}
            href={`?page=${i}`}
          >
            page {i}
          </Link>
        ))}
      </div>
    </div>
  );
}
Implementación de paginación en Nextjs-6

Siguiendo los enlaces que aparecen arriba, verá que los posts se presentan según la consulta.

Ahora, creemos un componente 'use client'.

Como es bastante molesto de implementar, dejémoslo a Claude para una vez.

Implementación de paginación en Nextjs-7

Ah... es dulce...

Claude, eres el mejor en React.

Luego eliges el primero, examinas el código y haces algunos cambios.

Implementación de paginación en Nextjs-8
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";

export default function Pagination({
  totalItems,
  itemsPerPage = 10,
  className = "",
}: {
  totalItems: number;
  itemsPerPage: number;
  className?: string;
}) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
  const currentPage = Number(searchParams.get("page")) || 1;

  const totalPages = Math.ceil(totalItems / itemsPerPage);

  // Inserta la página clicada en la consulta de la barra de direcciones
  function handlePageChange(page: number) {
    if (page >= 1 && page <= totalPages) {
      const params = new URLSearchParams(searchParams);
      params.set("page", page.toString());
      replace(`${pathname}?${params.toString()}`);
    }
  }

  // Crear un array de números de página para renderizar
  function renderPageNumbers() {
    const pages = [];
    const showAroundCurrent = 2;

    const startPage: number = Math.max(1, currentPage - showAroundCurrent);
    const endPage: number = Math.min(
      totalPages,
      currentPage + showAroundCurrent
    );

    // Manejo del inicio
    if (startPage > 1) {
      pages.push(1);
      if (startPage > 2) {
        pages.push("...");
      }
    }

    // Números alrededor de la página actual
    for (let i = startPage; i <= endPage; i++) {
      pages.push(i);
    }

    // Manejo del final
    if (endPage < totalPages) {
      if (endPage < totalPages - 1) {
        pages.push("...");
      }
      pages.push(totalPages);
    }

    return pages;
  }

  // El retorno debajo se puede ver por encima
  return (
    <div className={`flex items-center justify-center space-x-2 ${className}`}>
      <button
        onClick={() => handlePageChange(currentPage - 1)}
        disabled={currentPage === 1}
        className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
        aria-label="Página anterior"
      >
        <ChevronLeftIcon className="w-5 h-5" />
      </button>

      {renderPageNumbers().map((page, index) => (
        <button
          key={index}
          onClick={() => typeof page === "number" && handlePageChange(page)}
          disabled={page === "..."}
          className={`
            px-4 py-2 rounded-lg transition-colors duration-200
            ${typeof page === "number" ? "hover:bg-gray-100" : ""}
            ${
              page === currentPage
                ? "bg-blue-500 text-white hover:bg-blue-600"
                : ""
            }
            ${page === "..." ? "cursor-default" : "cursor-pointer"}
            disabled:cursor-not-allowed
          `}
          aria-label={typeof page === "number" ? `Página ${page}` : "Más páginas"}
          aria-current={page === currentPage ? "page" : undefined}
        >
          {page}
        </button>
      ))}

      <button
        onClick={() => handlePageChange(currentPage + 1)}
        disabled={currentPage === totalPages}
        className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
        aria-label="Página siguiente"
      >
        <ChevronRightIcon className="w-5 h-5" />
      </button>
    </div>
  );
}

Después de esto, agregué la paginación al layout de categoría para pruebas.

Probé con un número arbitrario de 100 posts totales y 6 posts por página.

// ...
export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
// ...
      <div>
        <Pagenation totalItems={100} itemsPerPage={6} />
      </div>
      <div>Probando paginación</div>
//...
}
Implementación de paginación en Nextjs-9

Funciona perfectamente.

Ahora, solo falta que el lado del servidor pase correctamente totalItem y itemsPerPage al componente de cliente.

Por supuesto, el cambio de getPostCategory en el ejemplo de antes es un extra.

5. Resumen

Estuve pensando mucho en cómo implementar la paginación pero lo resolví de una vez.

La tendencia más reciente es que los documentos oficiales son mejores que los libros.

Espero que mi artículo haya sido útil.

댓글을 불러오는 중...