Installation
pnpm add @expresscsv/reactnpm install @expresscsv/reactyarn add @expresscsv/reactYour Publishable API Key
You need a publishable key from the dashboard to connect the importer.
New accounts start with two environments: Production and Development. You can add more later.
For this example, go to your Development environment and grab the publishable key from there.
Full Example
import { useExpressCSV, x } from "@expresscsv/react";
const productSchema = x.row({
sku: x
.string()
.label("SKU")
// validate the SKU format
.refine(/^[A-Z]{3}-\d{4}$/, { message: "Format must be ABC-1234" }),
name: x.string().min(1).label("Product Name"),
price: x.number().min(0).currency("USD").label("Price"),
categories: x
.multiselect([
{ label: "Apparel", value: "apparel" },
{ label: "Electronics", value: "electronics" },
{ label: "Home Goods", value: "home-goods" },
])
.min(1)
.label("Category")
});
export function ImportProductsButton() {
const { open, isOpen } = useExpressCSV({
// your schema definition
schema: productSchema,
// your publishable key from the dashboard
publishableKey: "pk_test_...",
// categorise your imports by source (e.g. "product-import", "user-import")
importIdentifier: "product-import",
// title shown to users
title: "Import products",
});
const handleImport = () => {
open({
// how many records to include in each chunk
chunkSize: 500,
webhook: {
// your backend endpoint to receive the import
url: "/api/webhooks/products-import",
headers: {
// authorise with your backend
Authorization: "Bearer your-api-token",
},
metadata: {
// add any metadata you need to correlate the import with
// a specific user or action in your app
userId: "user-123",
},
},
// called when the import is complete
onComplete: () => {
console.log("Product import delivered");
},
// called when the user cancels the import
onCancel: () => {
console.log("Product import cancelled");
},
// called if a fatal error occurs
onError: (error) => {
console.error("Product delivery error", error);
},
});
};
return (
<button type="button" disabled={isOpen} onClick={handleImport}>
Import Products
</button>
);
}How Webhook Delivery Works
When the user finishes mapping and reviewing their data, ExpressCSV sends validated records to your webhook endpoint in chunks.
Each request includes the imported records plus metadata about the delivery:
{
records: [
{ sku: "ABC-1234", name: "T-Shirt", price: 29.99, category: "apparel" },
{ sku: "XYZ-5678", name: "Desk Lamp", price: 89.0, category: "home-goods" },
// ...
],
metadata: {
// passthrough values from webhook.metadata
userId: "user-123",
},
totalChunks: 3, // total number of webhook requests for this import
chunkIndex: 0, // zero-based index of the current chunk
totalRecords: 1200, // records across the full import
delivery: {
importIdentifier: "product-import",
deliveryId: "del_abc123",
timestamp: "2026-03-10T14:30:00.000Z",
},
}Receiving The Webhook
Here's a minimal Fastify endpoint that receives each chunk and stores the imported products for the current user:
import Fastify from "fastify";
const app = Fastify();
app.post("/webhooks/products-import", async (request, reply) => {
const token = request.headers.authorization;
if (token !== "Bearer your-api-token") {
return reply.status(401).send({ error: "Unauthorized" });
}
const { records, metadata, delivery, chunkIndex, totalChunks } = request.body as {
records: Array<Record<string, unknown>>;
metadata: { userId: string };
delivery: { importIdentifier: string; deliveryId: string };
chunkIndex: number;
totalChunks: number;
};
await db.products.insertMany({
// metadata is passed through unchanged from the client
userId: metadata.userId,
products: records,
});
request.log.info(
// chunkIndex/totalChunks let you track progress across the full import
`Processed chunk ${chunkIndex + 1}/${totalChunks} for user ${metadata.userId} ` +
// importIdentifier is passed through from useExpressCSV(...)
`for ${delivery.importIdentifier} (${delivery.deliveryId})`
);
return reply.status(200).send({ ok: true });
});Your endpoint should return a 2xx response to acknowledge each chunk. If it doesn't, 5xx and 429 responses are retried automatically (up to 5 attempts per chunk), while other 4xx responses are treated as permanent failures. Chunks are delivered serially, so the next chunk is only sent after the current one succeeds.
Preloading
By default, the widget preloads in a hidden iframe so it appears instantly when open() is called.
To disable preloading (shows a brief loading screen instead):
const { open } = useExpressCSV({
schema,
publishableKey: "pk_test_...",
importIdentifier: "user-import",
preload: false,
});What's Next
- Types — define field types, validation rules, and TypeScript types
- Styling — theme the widget to match your app
- Webhooks — deliver records to your backend
- API Reference — full option and return value reference