WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Component guides
  4. TanStack Table v9 Data Table: Filters, Bulk Actions, Mobile
  1. Blog
  2. Component guides
  3. TanStack Table v9 Data Table: Filters, Bulk Actions, Mobile
Component guides
Component guides

TanStack Table v9 Data Table: Filters, Bulk Actions, Mobile

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 12 min read

On this page

0%
  1. What changed in TanStack Table v9 for a data table?
  2. How do you set up TanStack Table v9 with filters, selection and pinning?
  3. How do you get filters, bulk actions and pinning without building the UI?
    1. Which filter fits which column?
  4. How should bulk actions work across pages?
  5. How do you set up server-side pagination in TanStack Table?
  6. What should a data table look like on a phone?
  7. Should you build on TanStack directly or use a component?
  8. What should you build next?
  9. FAQ
    1. What replaced useReactTable in TanStack Table v9?
    2. How do I do server-side pagination in TanStack Table v9?
    3. How do I pin columns in TanStack Table v9?
    4. Does the shadcn data table work on phones?
    5. Is the Wingo UI Data Table free?

TL;DR

A TanStack Table v9 data table starts with tableFeatures: you register sorting, filtering, pagination, pinning and selection once at module scope and pass them to useTable, which replaces useReactTable. For server pagination you set manualPagination, manualSorting and manualFiltering with a rowCount from your API, and column pinning now uses start and end instead of left and right. The library gives you state and row models only; the filter UI, the bulk bar and a phone layout are still yours to build or install.

Published Oct 9, 2026

TanStack Table v9 went stable in August 2026, and many data table tutorials still show the v8 API: useReactTable, getCoreRowModel() and columnPinning.left. Copy one into a v9 project and the import fails, because useReactTable is gone; switch to useTable and nothing sorts until you register the sorting feature. This tutorial builds an admin data table on TanStack Table v9: column filters, bulk actions on selected rows, pinned columns and server pagination, plus the card layout it should switch to on a phone. The first half is plain TanStack, so it works with any markup. The second half uses the Wingo UI Data Table, which we build, so weigh our opinions accordingly.

What changed in TanStack Table v9 for a data table?

Features became explicit. You declare sorting, filtering, pagination, pinning and selection in a tableFeatures() object, and the row model factories live in that object instead of in the table options. The renames that touch a data table, from the React migration guide (opens in a new tab) as of October 2026:

v8v9
useReactTable(options)useTable(options, selector?) with a required features option
getCoreRowModel: getCoreRowModel()automatic
getSortedRowModel: getSortedRowModel()sortedRowModel: createSortedRowModel() inside tableFeatures
columnPinning: { left, right }, column.pin("left")columnPinning: { start, end }, column.pin("start")
sortingFn, sortingFnssortFn, sortFns
table.getState()table.state, or table.Subscribe for one slice
createColumnHelper<Person>()createColumnHelper<typeof features, Person>()

Three behavior changes bite during a migration. In 9.2.4, row.getVisibleCells() comes from columnVisibilityFeature, so a table without it has only getAllCells(), which ignores pinning order. getIsSomeRowsSelected() returns true when every row is selected, so an indeterminate header checkbox needs getIsSomeRowsSelected() && !getIsAllRowsSelected(). And row.getToggleSelectedHandler() selects a range on Shift-click by default (enableRowRangeSelection: false turns it off).

The v9 announcement (opens in a new tab) (August 4, 2026) says the whole package grew from 14kb to 25kb, and that features you do not register stay out of the bundle. Register what the table uses and nothing else.

How do you set up TanStack Table v9 with filters, selection and pinning?

Build the features object and the columns once at module scope, then call useTable with the data. TanStack's docs ask for stable features, columns and data, and a fresh data array on every render invalidates every row model built from it. Here is a complete TanStack Table v9 example for a clients list:

tsx
// clients-grid.tsx
"use client";
import type { CSSProperties } from "react";
import {
columnFilteringFeature, columnPinningFeature, columnSizingFeature, columnVisibilityFeature,
createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel,
filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature,
rowSortingFeature, sortFn_alphanumeric, sortFn_basic, tableFeatures, useTable, type Column,
} from "@tanstack/react-table";
export type Client = { id: string; name: string; city: string; status: string; balance: number };
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
columnFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString, arrIncludesSome: filterFn_arrIncludesSome },
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
columnSizingFeature,
columnPinningFeature,
columnVisibilityFeature,
rowSelectionFeature,
});
const helper = createColumnHelper<typeof features, Client>();
const columns = helper.columns([
helper.display({
id: "select",
size: 40,
header: ({ table }) => (
<input
type="checkbox"
aria-label="Select page"
checked={table.getIsAllPageRowsSelected()}
// v9: "some" stays true when all are selected
ref={(el) => {
if (el) el.indeterminate = table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected();
}}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input type="checkbox" aria-label="Select row" checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} />
),
}),
helper.accessor("name", { header: "Client", size: 220, filterFn: "includesString", sortFn: "alphanumeric" }),
helper.accessor("city", { header: "City", filterFn: "arrIncludesSome" }),
helper.accessor("status", { header: "Status", filterFn: "arrIncludesSome" }),
helper.accessor("balance", { header: "Balance", sortFn: "basic" }),
]);
// fixed widths keep the sticky offsets true; pinned cells need a background
function cellStyle(column: Column<typeof features, Client, unknown>): CSSProperties {
const width = column.getSize();
const side = column.getIsPinned();
if (side === "start") return { width, position: "sticky", insetInlineStart: column.getStart("start"), zIndex: 1, background: "Canvas" };
if (side === "end") return { width, position: "sticky", insetInlineEnd: column.getAfter("end"), zIndex: 1, background: "Canvas" };
return { width };
}
export function ClientsGrid({ data }: { data: Client[] }) {
const table = useTable({
features,
columns,
data,
getRowId: (row) => row.id,
initialState: { columnPinning: { start: ["select", "name"], end: [] } },
});
return (
<div style={{ overflowX: "auto" }}>
<table style={{ tableLayout: "fixed", width: table.getTotalSize() }}>
<thead>
{table.getHeaderGroups().map((group) => (
<tr key={group.id}>
{group.headers.map((header) => (
<th key={header.id} style={cellStyle(header.column)}>
{header.column.getCanSort() ? (
<button type="button" onClick={header.column.getToggleSortingHandler()}>
<table.FlexRender header={header} />
</button>
) : (
<table.FlexRender header={header} />
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={cellStyle(cell.column)}>
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

The fixed layout and the widths matter. getStart("start") returns 40 for the name column, the select column's size, so the browser has to draw that column 40px wide, or the pinned name column sticks with a gap in front of it. Canvas is the page background in the active color scheme; swap in your own token.

The filter functions are registered by name, so filterFn: "arrIncludesSome" is type-checked against the registry. Filtering and bulk work go through the same instance:

ts
table.getColumn("status")?.setFilterValue(["Overdue"]);
const ids = table.getSelectedRowModel().rows.map((row) => row.original.id);

That is the whole job of the library: state, row models and handlers. Everything a person sees is still missing: a search box, filter menus with checkboxes and ranges, chips for the applied filters, a bar that swaps in when rows are selected, keyboard navigation between rows and a layout for 390px screens. If you started from the shadcn data table guide, it is written for TanStack Table v9 (opens in a new tab) as of October 2026 and adds sorting, a text filter, column visibility, client pagination, a row actions menu and a selected-row count. Server pagination, pinning, bulk actions and a phone layout are not in it.

How do you get filters, bulk actions and pinning without building the UI?

Install a component that builds the same kind of TanStack table and renders those parts. Our Data Table runs on TanStack Table v9 with its features registered once at module scope, and takes plain column objects:

bash
npx wingo-ui@latest login # once, with a Pro account
npx wingo-ui@latest add data-table
tsx
// clients-table.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Archive, Download, Pencil, Plus, Trash2 } from "lucide-react";
import { DataTable, createColumns } from "@/components/ui/data-table";
// your server actions; each takes the selected ids
import { archiveClients, deleteClients, exportClients } from "./actions";
export type Client = {
id: string;
name: string;
taxId: string;
city: string;
status: "Active" | "Overdue" | "Prospect";
owner: string;
balance: number;
lastInvoice: string | null;
};
const col = createColumns<Client>();
export const columns = col([
col.accessor("name", { header: "Client", filter: "text", pinned: "left", mobile: "title" }),
col.accessor("city", { header: "City", filter: "multi-select", mobile: "subtitle" }),
col.accessor("status", {
header: "Status",
type: "badge",
badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } },
filter: "multi-select",
mobile: "meta",
}),
col.accessor("owner", { header: "Owner", type: "avatar", filter: "select" }),
col.accessor("balance", { header: "Balance", type: "currency", footer: "sum", filter: "number-range" }),
col.accessor("lastInvoice", { header: "Last invoice", type: "relative", filter: "date-range" }),
// hidden by default, still found by the search
col.accessor("taxId", { header: "Tax ID", searchable: true, mobile: "hidden" }),
]);
export function ClientsTable({ clients }: { clients: Client[] }) {
const router = useRouter();
const [selected, setSelected] = useState<string[]>([]);
return (
<DataTable
data={clients}
columns={columns}
caption="Clients"
locale="en-US"
searchPlaceholder="Search by name, tax ID or city"
defaultColumnVisibility={{ taxId: false }}
quickFilters={[
{ id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } },
{ id: "big", label: "Over $10k", filter: (row) => row.balance > 10_000 },
]}
selectionMode="multiple"
selectedKeys={selected}
onSelectedKeysChange={setSelected}
bulkActions={(_rows, keys) => [
{ label: "Export", icon: <Download />, onSelect: () => exportClients(keys) },
{ label: "Archive", icon: <Archive />, onSelect: () => archiveClients(keys).then(() => setSelected([])) },
{ label: "Delete", icon: <Trash2 />, tone: "danger", onSelect: () => deleteClients(keys).then(() => setSelected([])) },
]}
rowActions={(row) => [{ label: "Edit", icon: <Pencil />, href: `/clients/${row.id}` }]}
toolbarActions={[{ label: "New client", icon: <Plus />, primary: true, onSelect: () => router.push("/clients/new") }]}
persistKey="clients"
/>
);
}

createColumns<Client>() checks every accessor key against the row type, and pinned: "left" becomes columnPinning.start in the TanStack state. Each header's menu can also pin, move or hide a column at runtime, and persistKey remembers visibility, order, widths, density and page size in local storage. The rowActions menu gets a ⋯ column pinned to the end, the same menu on right-click, and Shift+F10 on a focused row.

Select a few rows and the toolbar turns into the bulk bar; open a header's chevron to filter or pin that column, or press the Overdue chip. Below 768px each row is a card: long press one to start selecting
$ npx wingo-ui@latest add data-table
ProData Table docs

Which filter fits which column?

Pick the filter by the question people ask of the column, and know the value shape, because a server has to parse it. These React data table filters, with the values onColumnFiltersChange and the server query carry:

filterUse it forValue
textnames, emails, ids"cafe"
selectone owner, one plan"Emma Carter"
multi-selectstatuses, cities, tags["Active", "Overdue"]
number-rangebalances, quantities[1000, null] (either end may be null)
date-rangeinvoice dates, sign-ups{ from, to }, each a Date or null (ISO strings once JSON-encoded)

In client mode the select filters list the column's unique values with counts, and the search box matches every word across the searchable columns, accents and formatted values included: "cafe" finds Café and "12480" finds $12,480.00. Quick filters are chips for the two or three questions someone asks every morning. A chip with a columnFilter works in both modes; a chip with a filter function runs in the browser only.

How should bulk actions work across pages?

Act on the ids, not on the row objects. bulkActions receives (rows, keys): keys holds every selected id, including rows on other pages or hidden by a filter, while rows only holds the selected rows present in data. In server mode that is the current page, so an export built from rows silently drops everything selected on page 1 once you are on page 3.

While rows are selected the Toolbar switches to its bulk mode: an inline band on desktop, a floating bar on phones. The rest follows Gmail. Selecting a whole page in client mode shows a banner that offers every matching row, and rows that a new filter hides stay selected, with the banner saying how many. Shift-click selects a range, Ctrl or Cmd + A does what the header checkbox does, Escape clears. A bulk action that returns a promise shows its button loading until it settles, and a danger tone stays red, the floating phone bar included. For "Delete 240 clients" we still open an Alert Dialog from onSelect: a bulk delete deserves one more tap.

Clear the selection after an action removes rows. The table never drops a selected id on its own, since a missing id may sit on another page, so after a delete the bar would still count rows that no longer exist. That is why the example above controls selectedKeys.

How do you set up server-side pagination in TanStack Table?

Tell the table the server owns the work, give it the total, and fetch whenever the state changes. TanStack Table server side pagination in v9 is three flags and a count, and the row model factories can go, because the browser never sorts or slices:

ts
const serverFeatures = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowPaginationFeature });
// columns come from createColumnHelper<typeof serverFeatures, Client>()
const NO_ROWS: Client[] = [];
// in the component: result is your fetch for { pagination, sorting, columnFilters }
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useTable({
features: serverFeatures,
columns,
data: result.data?.rows ?? NO_ROWS,
rowCount: result.data?.total,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
});

The Data Table folds that wiring into mode="server": data is the current page, rowCount the total, and every change arrives in onQueryChange as one object with page, pageSize, sorting, globalFilter and columnFilters. That object is a ready-made query key for TanStack Query:

tsx
// server-clients-table.tsx
"use client";
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { DataTable, type DataTableQuery } from "@/components/ui/data-table";
import { deleteClients } from "./actions";
import { columns, type Client } from "./clients-table";
// must match the table's defaults: the first render does not call onQueryChange
const FIRST: DataTableQuery = { page: 1, pageSize: 25, sorting: [], globalFilter: "", columnFilters: [] };
const NO_ROWS: Client[] = [];
async function fetchClients(query: DataTableQuery): Promise<{ rows: Client[]; total: number }> {
const res = await fetch(`/api/clients?q=${encodeURIComponent(JSON.stringify(query))}`);
if (!res.ok) throw new Error(`Clients failed with ${res.status}`);
return res.json();
}
export function ServerClientsTable() {
const [query, setQuery] = useState(FIRST);
const [selected, setSelected] = useState<string[]>([]);
const { data, isPending, isFetching, error, refetch } = useQuery({
queryKey: ["clients", query],
queryFn: () => fetchClients(query),
placeholderData: keepPreviousData,
});
return (
<DataTable
mode="server"
data={data?.rows ?? NO_ROWS}
rowCount={data?.total ?? 0}
columns={columns}
onQueryChange={setQuery}
loading={isPending}
refreshing={isFetching && !isPending}
error={error ? "Clients could not load." : undefined}
onRetry={() => refetch()}
selectionMode="multiple"
selectedKeys={selected}
onSelectedKeysChange={setSelected}
bulkActions={(_rows, keys) => [
{
label: "Delete",
icon: <Trash2 />,
tone: "danger",
onSelect: async () => {
await deleteClients(keys);
setSelected([]);
await refetch();
},
},
]}
caption="Clients"
locale="en-US"
/>
);
}

keepPreviousData keeps the last page on screen while the next one loads, and refreshing slides a thin bar along the top edge instead of swapping the rows for skeletons. The search arrives debounced (300ms), and the page goes back to 1 when the search, a filter or the sort changes.

The route handler treats the query as untrusted input. Sort ids and page sizes come from a whitelist, and each filter id maps to a clause you wrote:

ts
// app/api/clients/route.ts
import type { NextRequest } from "next/server";
import type { DataTableQuery } from "@/components/ui/data-table";
import { findClients } from "@/server/clients";
const SORTABLE = new Set(["name", "city", "status", "owner", "balance", "lastInvoice"]);
const PAGE_SIZES = [10, 25, 50, 100];
export async function GET(request: NextRequest) {
let raw: Partial<DataTableQuery> = {};
try {
raw = JSON.parse(request.nextUrl.searchParams.get("q") ?? "{}");
} catch {}
const pageSize = PAGE_SIZES.includes(Number(raw.pageSize)) ? Number(raw.pageSize) : 25;
const page = Math.max(1, Math.floor(Number(raw.page)) || 1);
const first = (Array.isArray(raw.sorting) ? raw.sorting : []).find((s) => SORTABLE.has(s?.id));
const sort = first ? { id: first.id, desc: first.desc === true } : undefined;
// findClients checks the session and maps each filter id to a known SQL clause
const { rows, total } = await findClients({
offset: (page - 1) * pageSize,
limit: pageSize,
sort,
search: String(raw.globalFilter ?? "").slice(0, 100),
filters: Array.isArray(raw.columnFilters) ? raw.columnFilters : [],
});
return Response.json({ rows, total });
}

Two server-mode details. The table has no faceted values without the full data, so a select filter lists only its filterOptions, or the badge map's keys on a badge column: status works as is, while city and owner need an explicit filterOptions list (a select distinct on the server, for example). And quick filters need a columnFilter, since a filter function never reaches the server. If the page, sort and filters should live in the URL so a link reopens the same view, the Next.js admin dashboard tutorial does that with router.replace and a server component.

Sort, search, or select rows on page 1 and then on page 2 (long press a card on a phone): the query object the server receives is printed under the table, and the selection count keeps the rows from both pages
$ npx wingo-ui@latest add data-table
ProData Table docs

Server mode has a cost: every pause in typing is a request. When one request can load every row a user may see, client mode filters instantly and needs no query API. We switch past a few thousand rows, or when permissions decide which rows exist for a user.

What should a data table look like on a phone?

A card per row. Eight columns on a 390px screen show about two at a time, and the header that names them scrolls out of sight. Below 768px the Data Table renders each row as a card, built from the same mobile roles you saw in the columns:

  • title is the card heading (by default the first column), subtitle the line under it.
  • meta sits in the top corner (by default the first badge or the last number column).
  • Up to four field columns show as label and value pairs; hidden keeps a column off the card.

Long press a card to start selecting, then tap to add more, and the bulk bar floats at the bottom of the screen. Sort, filters and the row menu open as bottom sheets, and client mode appends rows with Load more while server mode keeps a compact Pagination pager. The switch is a CSS breakpoint, so the server HTML already has the right layout before hydration and nothing jumps. renderCard replaces the mapping with your own card, and mobileLayout="scroll" keeps the table scrolling sideways for the rare grid that people compare column by column. The reasoning behind those defaults, with 44px touch targets and bottom sheets, is in mobile-first React components.

For small read-only tables, such as invoice lines or a price list, the plain Table does the same card switch without TanStack, selection or a toolbar.

Should you build on TanStack directly or use a component?

Build directly when the table is one of a kind or when your design system already has the menus and sheets; use a component when you need the same admin table on twenty screens. The options we would consider, as of October 2026:

OptionLicense and priceServer modeBulk actionsPinningPhone layout
TanStack Table v9 (opens in a new tab) aloneMIT, freeState onlyState onlyState onlyYours
shadcn data table guide (opens in a new tab)MIT, freeLinked to TanStack docsSelected-row countNoNo
tablecn (opens in a new tab)MIT, freeYes, with Drizzle and PostgreSQLAction bar on selectionNot listedNot listed
Wingo UI Data TablePro: $8 a month, $80 a year or $150 lifetimeYes (mode="server")Yes, floating on phonesYes, plus header menuCards below 768px

If you want Notion-style advanced filters over a Postgres table, tablecn is the better free starting point. If you only need sorting, one text filter and client pagination, the shadcn guide is enough. The Data Table, with the Table, Pagination and Toolbar it builds on, is part of Wingo UI Pro.

What should you build next?

Open the playground on the Data Table docs, switch mode to server and watch the query object change as you sort and filter, then install it with npx wingo-ui@latest add data-table after a one-time CLI login. Other tables in the library, from the members table to the editable grid, are listed under tables, and more component tutorials live in the component guides category, starting with the React form components guide for the filter forms around your tables.

Components in this post

  • Data Table

    The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

    Pro
  • Table

    The library's one table look: semantic table parts plus a props API for small read-only tables that turn into cards on phones.

    Pro
  • Pagination

    Pages for tables and lists: numbers with ellipses, previous / next, page size, jump to page, a compact phone pager and load more.

    Pro
  • Toolbar

    The bar above a list or table: search, filter chips, applied filters, sort, a list / grid switch, the count, actions and a bulk mode.

    Pro

FAQ

What replaced useReactTable in TanStack Table v9?

useTable, which takes a required features option built with tableFeatures(). Row models such as sorting and pagination moved into that features object as factories (sortedRowModel: createSortedRowModel()), and useLegacyTable from @tanstack/react-table/legacy keeps the v8 API as a deprecated migration aid.

How do I do server-side pagination in TanStack Table v9?

Set manualPagination: true, pass the total as rowCount, control the pagination state and fetch the page your API returns for it. Add manualSorting and manualFiltering when the server also sorts and filters, and leave the matching row model factories out of tableFeatures, since the browser never runs them.

How do I pin columns in TanStack Table v9?

Register columnPinningFeature and set columnPinning to { start: [...], end: [...] }; v9 renamed left and right to start and end with no aliases. Register columnSizingFeature too, make the pinned cells position: sticky with column.getStart('start') or column.getAfter('end') as the offset, and give them a width and a background so the offsets line up and rows do not show through.

Does the shadcn data table work on phones?

As of October 2026 the shadcn data table guide covers sorting, filtering, column visibility, client pagination and row selection on TanStack Table v9, and has no phone layout: the table keeps its columns and scrolls sideways. A card per row below 768px is something you add yourself or get from a component that ships it.

Is the Wingo UI Data Table free?

No. The Data Table is part of Wingo UI Pro, and so are the Table, Pagination and Toolbar it builds on. The checkbox, dropdown menu, popover and other basics it uses are free on their own.

  • TanStack Table
  • Data table
  • React
  • shadcn/ui
  • Mobile UI
  • Next.js

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

How to Update shadcn Components Without Losing Edits

Update shadcn components without losing edits: what shadcn diff and add --diff show, why a 2-way diff hides whose change is whose, and how a 3-way merge works.

SRSerban Rusu·Oct 9, 2026·10 min read
Comparisons and alternatives

14 shadcn Alternatives in 2026: Price, Mobile and MCP

14 shadcn alternatives checked in October 2026: free and paid UI libraries by license, price, mobile behavior and MCP support, with a pick per use case.

SRSerban Rusu·Oct 9, 2026·19 min read
Building UI with AI agents

Component Library MCP: Make Agents Use Real Components

How a component library MCP server, rules files, llms.txt and a registry get Claude Code, Cursor and Codex to install real components instead of guessing.

SRSerban Rusu·Oct 9, 2026·25 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI