Express CSV Logo

Installation

pnpm add @expresscsv/react
npm install @expresscsv/react
yarn add @expresscsv/react

Your 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

On this page