
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.

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.

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()}
// ...
/>
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-debounceY 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.
}
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>
);
}

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.

Ah... es dulce...
Claude, eres el mejor en React.
Luego eliges el primero, examinas el código y haces algunos cambios.

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>
//...
}
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.
댓글을 불러오는 중...