Przejdź do treści

Zaawansowane tabele danych z TanStack Table w Next.js

TanStack Table to potężna biblioteka bez opinii na temat renderowania. Jak zbudować z nią tabelę z sortowaniem, filtrowaniem i paginacją w Next.js?

Maciej Sala

Founder StriveLab

5 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 17 lipca 2026)

Dlaczego TanStack Table w Next.js?

Słowo oznacza tu jedno: biblioteka nie narzuca Ci ani jednego piksela wyglądu. Całą warstwę wizualną budujesz sam, Tailwindem, dokładnie tak, jak chcesz.

Ten wpis dotyczy komponentu tabeli i jego logiki UI. Jeśli projektujesz listę indeksowalną dla SEO, zobacz osobno Paginacja w Next.js: Offset vs Cursor i Infinite Scroll oraz Wyszukiwarka z filtrami w Next.js — searchParams, debounce, URL state i UX.

Code
npm install @tanstack/react-table

Podstawowa tabela danych w TanStack Table

Code
// components/data-table.tsx
'use client'
 
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  type ColumnDef,
  type SortingState,
} from '@tanstack/react-table'
import { useId, useState } from 'react'
 
interface DataTableProps<TData, TValue> {
  data: TData[]
  columns: ColumnDef<TData, TValue>[]
  getRowId?: (row: TData) => string
  caption?: string
}
 
export function DataTable<TData, TValue>({
  data,
  columns,
  getRowId,
  caption = 'Tabela danych',
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([])
  const [globalFilter, setGlobalFilter] = useState('')
  const searchId = useId()
 
  const table = useReactTable({
    data,
    columns,
    getRowId,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    globalFilterFn: 'includesString',
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: { pagination: { pageSize: 10 } },
  })
 
  const filteredRowCount = table.getFilteredRowModel().rows.length
 
  return (
    <div>
      {/* Wyszukiwarka */}
      <label htmlFor={searchId} className="sr-only">
        Szukaj w tabeli
      </label>
      <input
        id={searchId}
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Szukaj..."
        className="mb-4 w-full max-w-sm rounded-lg border px-4 py-2"
      />
 
      {/* Tabela */}
      <div className="overflow-x-auto rounded-lg border">
        <table className="w-full">
          <caption className="sr-only">{caption}</caption>
          <thead className="bg-gray-50">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  const sorted = header.column.getIsSorted()
                  const canSort = header.column.getCanSort()
 
                  return (
                    <th
                      key={header.id}
                      scope="col"
                      aria-sort={
                        sorted === 'asc'
                          ? 'ascending'
                          : sorted === 'desc'
                            ? 'descending'
                            : canSort
                              ? 'none'
                              : undefined
                      }
                      className="px-4 py-3 text-left text-sm font-medium text-gray-600"
                    >
                      {header.isPlaceholder ? null : canSort ? (
                        <button
                          type="button"
                          onClick={header.column.getToggleSortingHandler()}
                          className="flex items-center gap-1 select-none hover:underline"
                        >
                          {flexRender(
                            header.column.columnDef.header,
                            header.getContext(),
                          )}
                          <span aria-hidden="true">
                            {sorted === 'asc'
                              ? '↑'
                              : sorted === 'desc'
                                ? '↓'
                                : '↕'}
                          </span>
                        </button>
                      ) : (
                        flexRender(
                          header.column.columnDef.header,
                          header.getContext(),
                        )
                      )}
                    </th>
                  )
                })}
              </tr>
            ))}
          </thead>
          <tbody className="divide-y">
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="hover:bg-gray-50">
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3 text-sm">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
            {table.getRowModel().rows.length === 0 && (
              <tr>
                <td
                  colSpan={table.getVisibleLeafColumns().length}
                  className="p-8 text-center"
                >
                  Brak wyników
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
 
      {/* Paginacja */}
      <div className="mt-4 flex items-center justify-between">
        <p className="text-sm text-gray-500">
          {filteredRowCount === 0
            ? 'Brak wyników'
            : `Strona ${table.getState().pagination.pageIndex + 1} z ${table.getPageCount()} (${filteredRowCount} wyników)`}
        </p>
        <div className="flex gap-2">
          <button
            type="button"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            className="rounded border px-3 py-1 disabled:opacity-50"
          >
            ← Poprzednia
          </button>
          <button
            type="button"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            className="rounded border px-3 py-1 disabled:opacity-50"
          >
            Następna →
          </button>
        </div>
      </div>
    </div>
  )
}

Type-safe definicja kolumn w TanStack Table

Kolumny opisujesz tablicą , której każda pozycja zawiera dwa wymagane pola określające nagłówek kolumny oraz sposób renderowania jej komórek:

Code
// app/dashboard/orders/columns.tsx
'use client'
 
import { type ColumnDef } from '@tanstack/react-table'
 
export interface Order {
  id: string
  customerName: string
  email: string
  total: number
  status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
  createdAt: string
}
 
export const orderColumns: ColumnDef<Order>[] = [
  {
    accessorKey: 'customerName',
    header: 'Klient',
    cell: ({ row }) => (
      <div>
        <p className="font-medium">{row.original.customerName}</p>
        <p className="text-xs text-gray-400">{row.original.email}</p>
      </div>
    ),
  },
  {
    accessorKey: 'total',
    header: 'Kwota',
    cell: ({ getValue }) => {
      const amount = getValue<number>()
      return new Intl.NumberFormat('pl-PL', {
        style: 'currency',
        currency: 'PLN',
      }).format(amount)
    },
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ getValue }) => {
      const status = getValue<Order['status']>()
      const statusMap: Record<
        Order['status'],
        { label: string; color: string }
      > = {
        pending: {
          label: 'Oczekujące',
          color: 'bg-yellow-100 text-yellow-800',
        },
        paid: { label: 'Opłacone', color: 'bg-green-100 text-green-800' },
        shipped: { label: 'Wysłane', color: 'bg-blue-100 text-blue-800' },
        delivered: { label: 'Dostarczone', color: 'bg-gray-100 text-gray-800' },
        cancelled: { label: 'Anulowane', color: 'bg-red-100 text-red-800' },
      }
      const { label, color } = statusMap[status]
      return (
        <span className={`rounded-full px-2 py-1 text-xs font-medium ${color}`}>
          {label}
        </span>
      )
    },
    filterFn: 'equals',
  },
  {
    accessorKey: 'createdAt',
    header: 'Data',
    cell: ({ getValue }) =>
      new Date(getValue<string>()).toLocaleDateString('pl-PL'),
  },
  {
    id: 'actions',
    header: '',
    enableSorting: false,
    cell: ({ row }) => (
      <div className="flex gap-2">
        <a
          href={`/dashboard/orders/${row.original.id}`}
          className="text-sm text-blue-600 hover:underline"
        >
          Szczegóły
        </a>
      </div>
    ),
  },
]

Definicje cell i header są funkcjami, więc nie są serializowalnymi propsami Reacta. Zaimportuj je po stronie klienta i przekaż z Server Component tylko dane:

Code
// app/dashboard/orders/orders-table.tsx
'use client'
 
import { DataTable } from '@/components/data-table'
import { orderColumns, type Order } from './columns'
 
export function OrdersTable({ data }: { data: Order[] }) {
  return (
    <DataTable
      data={data}
      columns={orderColumns}
      getRowId={(order) => order.id}
      caption="Lista zamówień"
    />
  )
}

TanStack Table w Next.js: Server Component i Client Table

Code
// app/dashboard/orders/page.tsx
import { db } from '@/lib/db'
import { OrdersTable } from './orders-table'
import type { Order } from './columns'
 
export default async function OrdersPage() {
  const records = await db.order.findMany({
    select: {
      id: true,
      customerName: true,
      email: true,
      total: true,
      status: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
    take: 500, // Limit biznesowy, nie uniwersalny próg wydajności.
  })
 
  const orders: Order[] = records.map((order) => ({
    ...order,
    total: Number(order.total),
    createdAt: order.createdAt.toISOString(),
  }))
 
  return (
    <div>
      <h1 className="mb-6 text-2xl font-bold">Zamówienia</h1>
      <OrdersTable data={orders} />
    </div>
  )
}

Dane są pobierane w Server Component, normalizowane do serializowalnego DTO i renderowane w Client Component. Nie traktuj ukrycia kolumny jako zabezpieczenia: wszystko, co przekażesz w data, użytkownik może odczytać w payloadzie RSC. Autoryzację i wybór pól wykonuj na serwerze.

Selekcja wierszy w tabeli danych

Code
// Dodaj do columns na początku:
{
  id: 'select',
  header: ({ table }) => (
    <input
      type="checkbox"
      aria-label="Zaznacz wszystkie wiersze na tej stronie"
      aria-checked={
        table.getIsSomePageRowsSelected()
          ? 'mixed'
          : table.getIsAllPageRowsSelected()
      }
      checked={table.getIsAllPageRowsSelected()}
      onChange={table.getToggleAllPageRowsSelectedHandler()}
    />
  ),
  cell: ({ row }) => (
    <input
      type="checkbox"
      aria-label={`Zaznacz wiersz ${row.id}`}
      checked={row.getIsSelected()}
      onChange={row.getToggleSelectedHandler()}
    />
  ),
}
Code
import { type RowSelectionState } from '@tanstack/react-table'
 
// W DataTable — dodaj row selection state
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
 
const table = useReactTable({
  // ...
  state: { sorting, globalFilter, rowSelection },
  onRowSelectionChange: setRowSelection,
  enableRowSelection: true,
  getRowId,
})
 
// Dostęp do zaznaczonych wierszy
const selectedRows = table.getFilteredSelectedRowModel().rows

Kilka porad, zanim ruszysz dalej:

  • Używaj stabilnych identyfikatorów. Stan selekcji opieraj na ID z bazy danych, a nie na indeksach wierszy.

  • Pamiętaj o ograniczeniach strony. getToggleAllPageRowsSelectedHandler zaznacza tylko bieżącą stronę, a getFilteredSelectedRowModel() zna wyłącznie aktualnie przekazane dane.

  • Weryfikuj uprawnienia na serwerze. Operacje masowe wykonuj po stronie backendu i zawsze sprawdzaj uprawnienia dla każdego ID; zaznaczenie w interfejsie to nie autoryzacja.

  • Zadbaj o stan indeterminate. w przypadku częściowego zaznaczenia ustaw właściwość DOM indeterminate na checkboxie, ponieważ aria-checked="mixed" obsługują tylko technologie asystujące.

Server-side table: paginacja z URL w Next.js

Przy dużych albo drogich zbiorach danych TanStack Table nadal zarządza stanem i UI, ale sortowanie, filtrowanie i paginacja dzieją się po stronie bazy. Pamiętaj, by nigdy nie mieszać klientowego sortowania z serwerową paginacją, bo nie posortujesz całego zbioru, ale tylko aktualnie pobraną stronę.

Najpierw parsuj searchParams bezpośrednio w komponencie serwerowym. Pamiętaj, aby parametry odpowiadające za sortowanie zawsze weryfikować za pomocą białej listy, zamiast bezwzględnie przekazywać wartości z URL do klauzuli orderBy.

Code
// app/dashboard/orders/page.tsx
import { db } from '@/lib/db'
import { ServerDataTable } from '@/components/server-data-table'
import type { Prisma } from '@prisma/client'
import type { Order } from './columns'
 
const PAGE_SIZE = 20
 
const SORT_MAP = {
  'createdAt.desc': [{ createdAt: 'desc' }, { id: 'desc' }],
  'createdAt.asc': [{ createdAt: 'asc' }, { id: 'asc' }],
  'total.desc': [{ total: 'desc' }, { id: 'desc' }],
  'total.asc': [{ total: 'asc' }, { id: 'asc' }],
} satisfies Record<string, Prisma.OrderOrderByWithRelationInput[]>
 
type SortKey = keyof typeof SORT_MAP
 
type SearchParams = {
  page?: string | string[]
  sort?: string | string[]
  filter?: string | string[]
}
 
function getSingleParam(value: string | string[] | undefined) {
  return typeof value === 'string' ? value : undefined
}
 
function isSortKey(value: string | undefined): value is SortKey {
  return value !== undefined && value in SORT_MAP
}
 
function parsePage(value: string | undefined) {
  if (!value || !/^\d+$/.test(value)) return 1
 
  const page = Number(value)
  return Number.isSafeInteger(page) && page > 0 ? page : 1
}
 
export default async function OrdersPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>
}) {
  const params = await searchParams
  const page = parsePage(getSingleParam(params.page))
  const sort = getSingleParam(params.sort)
  const sortKey: SortKey = isSortKey(sort) ? sort : 'createdAt.desc'
  const [sortId, sortDirection] = sortKey.split('.')
  const filter = getSingleParam(params.filter)?.trim().slice(0, 100) ?? ''
 
  const where = (
    filter
      ? {
          OR: [
            { customerName: { contains: filter, mode: 'insensitive' } },
            { email: { contains: filter, mode: 'insensitive' } },
          ],
        }
      : undefined
  ) satisfies Prisma.OrderWhereInput | undefined
 
  const [records, rowCount] = await db.$transaction([
    db.order.findMany({
      where,
      select: {
        id: true,
        customerName: true,
        email: true,
        total: true,
        status: true,
        createdAt: true,
      },
      skip: (page - 1) * PAGE_SIZE,
      take: PAGE_SIZE,
      orderBy: SORT_MAP[sortKey],
    }),
    db.order.count({ where }),
  ])
 
  const orders: Order[] = records.map((order) => ({
    ...order,
    total: Number(order.total),
    createdAt: order.createdAt.toISOString(),
  }))
 
  return (
    <ServerDataTable
      data={orders}
      rowCount={rowCount}
      pageIndex={page - 1}
      pageSize={PAGE_SIZE}
      sorting={[{ id: sortId, desc: sortDirection === 'desc' }]}
      globalFilter={filter}
    />
  )
}

Po stronie klienta stan tabeli jest w pełni kontrolowany przez parametry w adresie URL. Flagi manualPagination, manualSorting oraz manualFiltering informują TanStack Table, że przekazane dane zostały już odpowiednio przefiltrowane, posortowane i podzielone na strony.

Code
// components/server-data-table.tsx
'use client'
 
import {
  flexRender,
  getCoreRowModel,
  useReactTable,
  type SortingState,
} from '@tanstack/react-table'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useId, useState, useTransition } from 'react'
import { orderColumns, type Order } from '@/app/dashboard/orders/columns'
 
interface ServerDataTableProps {
  data: Order[]
  rowCount: number
  pageIndex: number
  pageSize: number
  sorting: SortingState
  globalFilter: string
}
 
export function ServerDataTable({
  data,
  rowCount,
  pageIndex,
  pageSize,
  sorting,
  globalFilter,
}: ServerDataTableProps) {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const searchId = useId()
  const [filterInput, setFilterInput] = useState(globalFilter)
  const [isPending, startTransition] = useTransition()
  const pagination = { pageIndex, pageSize }
 
  useEffect(() => setFilterInput(globalFilter), [globalFilter])
 
  useEffect(() => {
    if (filterInput === globalFilter) return
 
    const timeout = setTimeout(() => {
      const params = new URLSearchParams(searchParams.toString())
      params.set('page', '1')
      if (filterInput) params.set('filter', filterInput)
      else params.delete('filter')
 
      const query = params.toString()
      startTransition(() => {
        router.replace(query ? `${pathname}?${query}` : pathname, {
          scroll: false,
        })
      })
    }, 300)
 
    return () => clearTimeout(timeout)
  }, [filterInput, globalFilter, pathname, router, searchParams])
 
  function updateQuery(values: Record<string, string | null>) {
    const params = new URLSearchParams(searchParams.toString())
 
    for (const [key, value] of Object.entries(values)) {
      if (value) params.set(key, value)
      else params.delete(key)
    }
 
    const query = params.toString()
    startTransition(() => {
      router.replace(query ? `${pathname}?${query}` : pathname, {
        scroll: false,
      })
    })
  }
 
  const table = useReactTable({
    data,
    columns: orderColumns,
    getRowId: (order) => order.id,
    rowCount,
    state: {
      pagination,
      sorting,
      globalFilter,
    },
    manualPagination: true,
    manualSorting: true,
    manualFiltering: true,
    enableMultiSort: false,
    getCoreRowModel: getCoreRowModel(),
    onPaginationChange: (updater) => {
      const next = typeof updater === 'function' ? updater(pagination) : updater
 
      updateQuery({ page: String(next.pageIndex + 1) })
    },
    onSortingChange: (updater) => {
      const next = typeof updater === 'function' ? updater(sorting) : updater
      const firstSort = next[0]
 
      updateQuery({
        page: '1',
        sort: firstSort
          ? `${firstSort.id}.${firstSort.desc ? 'desc' : 'asc'}`
          : null,
      })
    },
  })
 
  return (
    <div aria-busy={isPending}>
      <label htmlFor={searchId} className="sr-only">
        Szukaj w zamówieniach
      </label>
      <input
        id={searchId}
        value={filterInput}
        onChange={(event) => setFilterInput(event.target.value)}
        placeholder="Szukaj..."
        className="mb-4 w-full max-w-sm rounded-lg border px-4 py-2"
      />
 
      <div className="overflow-x-auto">
        <table className="w-full">
          <caption className="sr-only">Lista zamówień</caption>
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  const sorted = header.column.getIsSorted()
                  const canSort = ['createdAt', 'total'].includes(
                    header.column.id,
                  )
 
                  return (
                    <th
                      key={header.id}
                      scope="col"
                      aria-sort={
                        sorted === 'asc'
                          ? 'ascending'
                          : sorted === 'desc'
                            ? 'descending'
                            : canSort
                              ? 'none'
                              : undefined
                      }
                    >
                      {header.isPlaceholder ? null : canSort ? (
                        <button
                          type="button"
                          onClick={header.column.getToggleSortingHandler()}
                        >
                          {flexRender(
                            header.column.columnDef.header,
                            header.getContext(),
                          )}
                          <span aria-hidden="true">
                            {sorted === 'asc'
                              ? ' ↑'
                              : sorted === 'desc'
                                ? ' ↓'
                                : ' ↕'}
                          </span>
                        </button>
                      ) : (
                        flexRender(
                          header.column.columnDef.header,
                          header.getContext(),
                        )
                      )}
                    </th>
                  )
                })}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
            {table.getRowModel().rows.length === 0 && (
              <tr>
                <td colSpan={table.getVisibleLeafColumns().length}>
                  Brak wyników
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
 
      <button
        type="button"
        onClick={() => table.previousPage()}
        disabled={!table.getCanPreviousPage()}
      >
        Poprzednia
      </button>
      <button
        type="button"
        onClick={() => table.nextPage()}
        disabled={!table.getCanNextPage()}
      >
        Następna
      </button>
    </div>
  )
}

Lokalny stan filterInput zapewnia natychmiastową responsywność interfejsu bez czekania na odpowiedź serwera, a mechanizm debounce skutecznie ogranicza liczbę zapytań nawigacyjnych. Zastosowanie router.replace zapobiega zaśmiecaniu historii przeglądarki każdą zmianą filtrów czy sortowania, z kolei po router.push sięgaj tylko wtedy, gdy celowo chcesz zachować każdy krok w historii. Przechowywanie stanu bezpośrednio w URL pozwala natomiast na odtworzenie dokładnego widoku po odświeżeniu strony oraz ułatwia dzielenie się bezpośrednim linkiem.

Stabilność i wydajność zapytań

Przy paginacji offsetowej dodaj do sortowania unikalny klucz rozstrzygający remisy, na przykład id. Bez niego rekordy o tej samej dacie lub kwocie mogą zmieniać kolejność między zapytaniami, co prowadzi do duplikatów albo pominięć między stronami. Indeksy w bazie powinny odpowiadać najczęstszym filtrom i sortowaniom, ale dobieraj je na podstawie planu zapytania, nie automatycznie dla każdej kolumny.

Duży skip staje się kosztowny przy odległych stronach. Jeśli użytkownik głównie przechodzi do następnej i poprzedniej partii, rozważ paginację kursorową opartą na tym samym stabilnym porządku. Pamiętaj też, że dokładne count() może być drogie na dużej, intensywnie filtrowanej tabeli — czasem lepsza jest informacja „jest następna strona” niż kosztowne obliczanie pełnej liczby stron.

Dostępność, responsywność i wirtualizacja

Sortowanie przypinaj do prawdziwego <button> wewnątrz <th>, a stan wystawiaj przez aria-sort, z kolei sam onClick na nagłówku nie zapewnia obsługi klawiatury. Trzeba dodać też podpis tabeli, etykietę wyszukiwarki, komunikat pustego wyniku i poziome przewijanie na wąskich ekranach. Przy bardzo wielu renderowanych wierszach sama paginacja nie zawsze wystarczy, ponieważ TanStack Table nie ma wbudowanej wirtualizacji, ale można połączyć go z TanStack Virtual albo react-window.

W testach sprawdź co najmniej zmianę kierunku sortowania, reset strony po filtrze, nieprawidłowe parametry URL, pusty wynik, nawigację wstecz/dalej oraz obsługę nagłówków klawiaturą. W trybie serwerowym szczególnie ważny jest test, że backend i UI obsługują dokładnie ten sam zestaw kolumn sortowalnych.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

TanStack Table, AG Grid czy MUI DataGrid?

TanStack Table jest headless i bardzo lekki, nie narzuca żadnych stylów, dając pełną kontrolę nad wyglądem. Jest to idealne rozwiązanie dla customowego UI w Tailwind. AG Grid to rozwiązanie klasy enterprise z mnóstwem wbudowanych funkcji i własnymi stylami, ale znacznie cięższe. MUI DataGrid wpisuje się w Material Design i jest wygodny, jeśli i tak używasz MUI. Dla projektów z własnym design systemem i Tailwindem naturalnym wyborem jest TanStack Table.

Czy mogę eksportować dane tabeli do CSV?

Tak. TanStack Table daje dostęp do aktualnie przefiltrowanych i posortowanych wierszy przed paginacją przez table.getPrePaginationRowModel().rows. getFilteredRowModel() uwzględnia filtry, ale jeszcze nie sortowanie. getRowModel() zwraca natomiast tylko aktualną stronę. Przy tabeli serwerowej modele TanStack znają wyłącznie pobrany wycinek, więc eksport całego wyniku musi wykonać osobny endpoint z tymi samymi filtrami, autoryzacją i limitem rozmiaru.

Jak obsłużyć tabele na urządzeniach mobilnych?

Są dwa dobre i sprawdzone podejścia. Pierwsze to poziomy scroll, czyli owijasz tabelę w kontener z overflow-x-auto, a dzięki temu na wąskim ekranie można ją przewijać w bok. Drugie, bardziej dopracowane, to zamiana tabeli na listę kart na małych ekranach, gdzie każdy wiersz staje się osobną kartą. TanStack Table wspiera też sterowanie widocznością kolumn, więc możesz po prostu ukryć mniej istotne kolumny na mobile.

Kiedy przejść z paginacji klienckiej na serwerową?

Nie ma uniwersalnego progu liczby rekordów. TanStack Table potrafi sprawnie przetwarzać tysiące prostych wierszy, ale decyzję podejmuj na podstawie rozmiaru odpowiedzi, liczby i złożoności kolumn, kosztu zapytania, pamięci urządzeń docelowych oraz czasu sortowania i filtrowania. Gdy nie chcesz pobierać całego zbioru albo potrzebujesz globalnie spójnych wyników, przejdź na tryb serwerowy ze searchParams.

Dlaczego dane pobierać w Server Component, a tabelę renderować w Client Component?

Bo to najlepiej wykorzystuje model App Routera. Server Component pobiera dane bezpośrednio z bazy bez wysyłania kodu zapytań do przeglądarki i bez dodatkowego round-tripu API. Sama tabela wymaga jednak interaktywności (sortowania, filtrowania, klikania), więc musi być Client Componentem z 'use client'. Do klienta przekazuj jednak wyłącznie serializowalne DTO. Definicje kolumn zawierają funkcje renderujące, dlatego importuj je w komponencie klienckim, zamiast przekazywać jako props z Server Component.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Pobieranie danych w React: React Query vs SWR vs RSC

Data fetching , czyli sztuka sensownego pobierania danych, to bodajże najstarszy problem w świecie Reacta. Napisanie podstawowego useEffect wraz z fetch jest proste, ale dopisanie do niego logiki, która poprawnie obsłuży cache, usunie zdublowane zapytania, bezbłędnie zadba o powtórzenia w razie błędów retry i elegancko wybroni się przed nadpisywaniem starych wyników race conditions ... to już grubszy temat.

Maciej Sala

Maciej Sala

Founder StriveLab

Wyszukiwarka z filtrami w Next.js — searchParams, debounce, URL state i UX

Użytkownik ustawia trzy filtry, znajduje idealny wynik, wysyła link koledze — a tamten widzi pustą wyszukiwarkę. Bo filtry siedziały w useState i zginęły poza jego przeglądarką. URL state rozwiązuje to u źródła: cały stan filtrów żyje w adresie, więc przetrwa odświeżenie, udostępnienie i przycisk „wstecz”, a do tego jest czytelny dla Server Components. Pokazuję, jak zbudować taką wyszukiwarkę — z debounce , skeletonami, walidacją parametrów i SEO.

Maciej Sala

Maciej Sala

Founder StriveLab

Paginacja w Next.js: Offset vs Cursor i Infinite Scroll

Paginacja wydaje się prostym tematem, dopóki nie trzeba pogodzić trzech rzeczy naraz: dobrego UX, wydajności zapytań do bazy i tego, żeby Google poprawnie zaindeksował wszystkie strony. W Next.js App Router wybieramy, czy używamy numerycznej paginacji w URL czy infinite scroll, offset czy cursor po stronie bazy, adres kanoniczny czy notFound dla błędnych adresów. W tym artykule przechodzę przez warianty z kodem i rekomendacjami.

Maciej Sala

Maciej Sala

Founder StriveLab