Displaying tabular data is one of the most common tasks in any Shopify app. You might be showing orders, product variants, customer records, payout history, or inventory levels. Polaris provides two table components: DataTable for simple, static tables and IndexTable for resource-list tables with bulk actions, row selection, and filtering. This tutorial covers both, with a focus on the sortable patterns that most tutorial articles skip. By the end you'll have a sortable, filterable product table with row selection and a bulk action.
What you'll build
Part 1: A DataTable showing inventory summary per product variant with client-side column sorting. Part 2: An IndexTable with row selection checkboxes, a search filter, and a bulk 'Archive' action that calls the Admin GraphQL API's productUpdate mutation.
Before you start
- @shopify/polaris v12 or later installed and AppProvider configured
- A Remix app with @shopify/shopify-app-remix configured and authenticated
- A development store with at least 10 products and multiple variants
Fetch product data in the Remix loader
Both DataTable and IndexTable are purely presentational — they don't fetch data themselves. You fetch product data in the Remix loader and pass it as props. For the inventory DataTable, fetch products with their variants and inventory quantities using the Admin GraphQL API.
Note that inventory quantities live on InventoryItem, not directly on ProductVariant. Use the inventoryItem.inventoryLevel(locationId: ...) field or inventoryItem.inventoryLevels(first: 1) to get quantities. For a simple display table you can use totalInventory on the product level, which sums across all locations.
const PRODUCTS_QUERY = `#graphql
query GetProductsWithInventory($first: Int!) {
products(first: $first, sortKey: TITLE) {
nodes {
id
title
status
totalInventory
variants(first: 50) {
nodes {
id
title
price
inventoryQuantity
sku
}
}
}
}
}
` as const;
export async function loader({ request }: LoaderFunctionArgs) {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(PRODUCTS_QUERY, {
variables: { first: 50 },
});
const { data } = await response.json();
return json({ products: data.products.nodes });
}Expected outcome: The loader returns an array of up to 50 products, each with variants and inventory quantities.
Render a sortable DataTable
DataTable accepts columnContentTypes (an array of 'text' or 'numeric'), headings (string array), and rows (a 2D array where each inner array is one row). For sorting, pass sortable (a boolean array of which columns are sortable), defaultSortDirection, onSort callback, and the sortColumnIndex/sortDirection controlled state.
DataTable's rows are plain React nodes, not typed objects — each cell can be a string, number, or JSX element. Use this to render a Badge for the product status column: <Badge tone="success">Active</Badge> vs <Badge>Draft</Badge>. Sort logic lives in your component state — sort the rows array before passing it to DataTable.
import { DataTable, Badge, Page, Card } from '@shopify/polaris';
import { useState, useMemo } from 'react';
import { useLoaderData } from '@remix-run/react';
export default function InventoryTable() {
const { products } = useLoaderData<typeof loader>();
const [sortIndex, setSortIndex] = useState(0);
const [sortDir, setSortDir] = useState<'ascending' | 'descending'>('ascending');
const rows = useMemo(() => {
const allRows = products.flatMap((p) =>
p.variants.nodes.map((v) => ([
p.title,
v.title === 'Default Title' ? '—' : v.title,
v.sku ?? '—',
v.price,
v.inventoryQuantity,
<Badge tone={p.status === 'ACTIVE' ? 'success' : undefined}>
{p.status.charAt(0) + p.status.slice(1).toLowerCase()}
</Badge>,
]))
);
return [...allRows].sort((a, b) => {
const valA = a[sortIndex];
const valB = b[sortIndex];
const cmp = String(valA).localeCompare(String(valB), undefined, { numeric: true });
return sortDir === 'ascending' ? cmp : -cmp;
});
}, [products, sortIndex, sortDir]);
return (
<Page title="Inventory">
<Card padding="0">
<DataTable
columnContentTypes={['text','text','text','numeric','numeric','text']}
headings={['Product','Variant','SKU','Price','Qty','Status']}
rows={rows}
sortable={[true, false, false, true, true, false]}
defaultSortDirection="ascending"
initialSortColumnIndex={0}
onSort={(idx, dir) => { setSortIndex(idx); setSortDir(dir); }}
/>
</Card>
</Page>
);
}Expected outcome: The inventory table renders with sortable Product, Price, and Quantity columns. Clicking a column header sorts the rows client-side and flips the sort indicator arrow.
Switch to IndexTable for row selection
IndexTable is designed for resource lists where you need to select rows and perform bulk actions. Unlike DataTable, IndexTable uses a slot-based API: you define column headings with the headings prop, then render IndexTable.Row and IndexTable.Cell children for each data row. This gives you more rendering control but requires more boilerplate.
Selection state is managed with the useIndexResourceState hook from @shopify/polaris. Pass your resource items array to it and it returns selectedResources, allResourcesSelected, handleSelectionChange, and clearSelection. Wire these to IndexTable's selectedItemsCount, onSelectionChange, and promotedBulkActions props.
import {
IndexTable, useIndexResourceState, Text,
Badge, Page, Card
} from '@shopify/polaris';
import { useFetcher, useLoaderData } from '@remix-run/react';
export default function ProductsIndex() {
const { products } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const {
selectedResources,
allResourcesSelected,
handleSelectionChange,
clearSelection,
} = useIndexResourceState(products);
const promotedBulkActions = [
{
content: 'Archive products',
onAction: () => {
fetcher.submit(
{ ids: selectedResources.join(',') },
{ method: 'POST', action: '/api/products/archive' }
);
clearSelection();
},
},
];
const rowMarkup = products.map(({ id, title, status, totalInventory }, idx) => (
<IndexTable.Row
id={id}
key={id}
selected={selectedResources.includes(id)}
position={idx}
>
<IndexTable.Cell>
<Text variant="bodyMd" fontWeight="bold" as="span">{title}</Text>
</IndexTable.Cell>
<IndexTable.Cell>
<Badge tone={status === 'ACTIVE' ? 'success' : undefined}>
{status.charAt(0) + status.slice(1).toLowerCase()}
</Badge>
</IndexTable.Cell>
<IndexTable.Cell>{totalInventory}</IndexTable.Cell>
</IndexTable.Row>
));
return (
<Page title="Products">
<Card padding="0">
<IndexTable
resourceName={{ singular: 'product', plural: 'products' }}
itemCount={products.length}
selectedItemsCount={allResourcesSelected ? 'All' : selectedResources.length}
onSelectionChange={handleSelectionChange}
promotedBulkActions={promotedBulkActions}
headings={[
{ title: 'Product' },
{ title: 'Status' },
{ title: 'Inventory', alignment: 'end' },
]}
>
{rowMarkup}
</IndexTable>
</Card>
</Page>
);
}Expected outcome: The products table shows checkboxes. Selecting rows reveals the 'Archive products' bulk action button in the table header. The bulk action posts selected IDs to the archive endpoint.
Add a search filter with Filters component
IndexTable doesn't include a built-in search box. The idiomatic Polaris pattern is to place a Filters component above the table. Filters renders the search input and any additional filter chips. For a simple text search, pass queryValue, onQueryChange, onQueryClear, and an empty filters array.
Filter the displayed rows by the query string using useMemo. For server-side filtering, push the search term into a URL search param with React Router's useSearchParams and re-fetch in the loader. Server-side is preferred for large datasets; client-side works fine for under 200 rows.
import { Filters } from '@shopify/polaris';
import { useState, useMemo } from 'react';
function FilterableProductTable({ products }) {
const [query, setQuery] = useState('');
const filtered = useMemo(
() => products.filter((p) =>
p.title.toLowerCase().includes(query.toLowerCase())
),
[products, query]
);
return (
<>
<Filters
queryValue={query}
filters={[]}
onQueryChange={setQuery}
onQueryClear={() => setQuery('')}
onClearAll={() => setQuery('')}
/>
{/* Pass filtered to IndexTable instead of products */}
</>
);
}Expected outcome: A search box appears above the table. Typing in it immediately narrows the visible rows without a page reload.
Handle the archive bulk action on the server
The bulk archive action posts product GIDs to an API route. In that route's action handler, run a productUpdate mutation for each selected product, setting status: ARCHIVED. Use Promise.all to run mutations in parallel — but be mindful of GraphQL API cost limits. For up to 20 products, parallel mutations are safe. For larger selections, consider serial processing with a short delay or use a bulk mutation.
// app/routes/api.products.archive.tsx
import type { ActionFunctionArgs } from '@shopify/remix-oxygen';
const ARCHIVE_MUTATION = `#graphql
mutation ArchiveProduct($id: ID!) {
productUpdate(input: { id: $id, status: ARCHIVED }) {
product { id status }
userErrors { field message }
}
}
` as const;
export async function action({ request }: ActionFunctionArgs) {
const { admin } = await authenticate.admin(request);
const formData = await request.formData();
const ids = String(formData.get('ids')).split(',').filter(Boolean);
const results = await Promise.all(
ids.map((id) =>
admin.graphql(ARCHIVE_MUTATION, { variables: { id } }).then((r) => r.json())
)
);
const errors = results.flatMap((r) => r.data?.productUpdate?.userErrors ?? []);
if (errors.length > 0) {
return json({ success: false, errors }, { status: 422 });
}
return json({ success: true, archived: ids.length });
}Expected outcome: Selected products are archived via the productUpdate mutation. The fetcher in the products route re-validates automatically after the action completes, removing archived products from the list.
What's next
For very large datasets (thousands of rows), replace client-side filtering with URL-based server filtering in the loader using the Admin GraphQL API's query parameter on the products connection. Pair it with Polaris Pagination for cursor-based navigation. The IndexTable supports a loading state via the loading prop — set this to true while the Remix fetcher is submitting to show a native Polaris skeleton loading overlay.
Common DataTable and IndexTable gotchas
The most common DataTable mistake is passing rows with mismatched lengths. If you declare 5 headings but one of your rows only has 4 cells, DataTable will render the row misaligned without throwing an error. Always verify that every row array has exactly the same number of elements as your headings array. TypeScript helps here: define a tuple type for your row and TypeScript will catch length mismatches at compile time.
For IndexTable, the most common gotcha is the id prop on IndexTable.Row. The id must be a string that uniquely identifies each row and matches the IDs passed to useIndexResourceState. If you use Shopify GIDs as IDs (gid://shopify/Product/123456789), that works fine — useIndexResourceState manages them as opaque strings. Just make sure the id on the row matches what's in the products array passed to useIndexResourceState.
Another IndexTable gotcha: the selectedItemsCount prop accepts either a number or the string 'All'. When allResourcesSelected is true (the user clicked 'Select all' in the header), you must pass 'All' rather than the total count — this renders the 'Deselect all' UI correctly in the bulk action bar. Passing the numeric count when allResourcesSelected is true will show the wrong count in the UI.
Row click navigation with IndexTable
In a resource list, clicking a row typically navigates to a detail page. IndexTable.Row supports this via the onClick and url props. Use url to set a navigation URL on the row — clicking anywhere in the row (except the checkbox) will navigate to that URL. This pattern is preferred over an onClick handler because it renders as a native anchor element, which supports browser features like 'open in new tab' and is accessible to screen readers.
If you need to handle both row click (navigate to detail) and checkbox click (select for bulk action), you don't need any special handling — Polaris handles the distinction automatically. Clicking the checkbox selects the row; clicking anywhere else on the row triggers the url navigation. Set url={`/app/products/${productId}`} on each IndexTable.Row and navigation works with zero additional event handler code.
Frequently asked questions
- Which component should I use for showing a simple list of key-value pairs?
Neither DataTable nor IndexTable. For key-value pairs (like an order summary or product details panel), use the DescriptionList component. It renders a definition list (<dl>) with label-value pairs and handles wrapping and spacing correctly. DataTable is for tabular data with headers; DescriptionList is for structured metadata.
- Can I use DataTable to show nested rows or expandable rows?
DataTable doesn't support expandable rows natively. If you need expansion, use Collapsible inside a regular BlockStack, or build the expand/collapse behavior manually using IndexTable with rows that conditionally render sub-rows. For tree-structured data (like a category hierarchy), consider a different layout entirely — Polaris's tree-table use case isn't well-served by either table component.
