@expresscsv/sdk
v1.0.1
Published
SDK for integrating the importer
Maintainers
Readme
@expresscsv/sdk
A TypeScript SDK for embedding the ExpressCSV CSV importer into any web application. Define a schema, open the importer, and receive validated, typed data in chunks.
If you want to define schemas in shared or backend code without any frontend dependencies, use @expresscsv/schemas for the schema definition itself, then pass that schema into @expresscsv/sdk.
Installation
# Using pnpm
pnpm add @expresscsv/sdk
# Using npm
npm install @expresscsv/sdk
# Using yarn
yarn add @expresscsv/sdkLooking for React? Use
@expresscsv/reactfor a hook-based integration.
Quick Start
import { CSVImporter, x } from "@expresscsv/sdk";
const schema = x.row({
name: x.string().label("Full Name"),
email: x.string().email().label("Email Address"),
age: x.number().label("Age").min(18).max(120),
});
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
title: "Import Users",
});
// Open the importer and process data in chunks
importer.open({
onData: (chunk, next) => {
console.log(`Chunk ${chunk.chunkIndex + 1}/${chunk.totalChunks}`);
console.log("Session:", chunk.sessionId);
console.log("Records:", chunk.records);
// Process your validated, typed records here
next();
},
onComplete: ({ sessionId, deliveryId }) => {
console.log("All chunks processed successfully for", sessionId);
},
onError: (error, { sessionId }) => {
console.error("Import failed for", sessionId, error);
},
});If your schema is assembled dynamically at runtime, still use x.row(...). Just note that dynamic schema assembly can widen TypeScript inference, so use it intentionally when you need runtime-driven columns.
Have your backend keep the ExpressCSV secret key, call the ExpressCSV session-creation endpoint, and return a short-lived importer auth token to the browser via getAuthToken. The SDK opens the importer immediately, then completes the initial session bootstrap in the background.
Delivery
ExpressCSV delivers imported data through onData. Your app receives validated chunks in the browser and can forward them to your backend using whatever request shape fits your stack.
importer.open({
chunkSize: { unit: "kb", value: 500 },
onData: async (chunk, next) => {
const response = await fetch("/your-api/import-users/chunks", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
sessionId: chunk.sessionId,
deliveryId: chunk.deliveryId,
chunkIndex: chunk.chunkIndex,
records: chunk.records,
totalChunks: chunk.totalChunks,
totalRecords: chunk.totalRecords,
}),
});
if (!response.ok) {
throw new Error("Backend rejected this import chunk");
}
next();
},
onComplete: async ({ sessionId, deliveryId }) => {
await fetch("/your-api/import-users/complete", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ sessionId, deliveryId }),
});
},
});Each delivered chunk includes:
recordssessionIddeliveryIdchunkIndextotalChunkstotalRecords
Each time the user finishes the import, ExpressCSV creates a new deliveryId for that sessionId. Your onData code can fail, and the user can retry delivery without starting the import over. Store chunks by (sessionId, deliveryId, chunkIndex), then finalize the deliveryId from onComplete after all of its chunks are accepted.
Preloading
By default, the SDK preloads the importer in a hidden iframe for instant display when open() is called. This provides the best user experience while the initial session bootstrap continues in the background.
// Preload is enabled by default
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
});
// Later, the importer appears instantly
importer.open({ onData: (chunk, next) => { /* ... */ next(); } });To disable preloading (there will be a brief loading screen instead):
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
preload: false,
});Template Downloads
Generated templates can include example rows that match your schema.
import { CSVImporter, x, type Infer } from "@expresscsv/sdk";
const candidateSchema = x.row({
firstName: x.string().label("First Name"),
email: x.string().email().label("Email"),
role: x.select([
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]).label("Role"),
});
const importer = new CSVImporter({
schema: candidateSchema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "candidate-import",
templateDownload: {
source: "generate",
formats: ["csv", "xlsx"],
exampleRows: () => [
{
firstName: "Alice",
email: "[email protected]",
role: "admin",
},
],
},
});Theming and Styling
Customize the importer's appearance with the theme, colorMode, customCSS, and fonts options.
Theme
Use the theme option to override CSS variables (colors, radius, typography). Pass either a single theme (applies to both light and dark) or a dual-mode theme with separate light/dark values:
import { CSVImporter, x, type Theme } from "@expresscsv/sdk";
// Single theme (both modes)
const theme: Theme = {
primary: "#4F46E5",
"primary-foreground": "#ffffff",
background: "#ffffff",
foreground: "#0f172a",
border: "#e5e7eb",
ring: "#A5B4FC",
radius: "0.5rem",
};
// Dual-mode (light and dark)
const dualTheme: Theme = {
modes: {
light: {
primary: "#4F46E5",
background: "#ffffff",
foreground: "#0f172a",
},
dark: {
primary: "#a5b4fc",
background: "#09090b",
foreground: "#fafafa",
},
},
};
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
theme,
});Theme variables:
| Variable | Default |
|---|---|
| radius | 0.625rem |
| background | oklch(1 0 0) |
| foreground | oklch(0.145 0 0) |
| card | oklch(1 0 0) |
| card-foreground | oklch(0.145 0 0) |
| popover | oklch(1 0 0) |
| popover-foreground | oklch(0.145 0 0) |
| primary | oklch(0.205 0 0) |
| primary-foreground | oklch(0.985 0 0) |
| secondary | oklch(0.97 0 0) |
| secondary-foreground | oklch(0.205 0 0) |
| muted | oklch(0.97 0 0) |
| muted-foreground | oklch(0.556 0 0) |
| accent | oklch(0.7 0.2 145) |
| accent-foreground | oklch(0.985 0 0) |
| destructive | oklch(0.577 0.245 27.325) |
| destructive-foreground | oklch(0.985 0 0) |
| success | oklch(0.7 0.2 145) |
| success-foreground | oklch(0.985 0 0) |
| warning | oklch(0.769 0.188 70) |
| warning-foreground | oklch(0.985 0 0) |
| border | oklch(0.922 0 0) |
| input | oklch(0.922 0 0) |
| ring | oklch(0.708 0 0) |
| font-title | inherit |
| font-body | inherit |
Color Mode
Control light/dark mode with colorMode:
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
colorMode: "system", // 'light' | 'dark' | 'system'
});Custom CSS
Inject custom CSS for fine-grained styling overrides.
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
customCSS: `
.ecsv [data-step="upload"] {
border-radius: 1rem;
}
.ecsv button {
font-weight: 600;
}
`,
});Custom Fonts
Load custom fonts via the fonts option:
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
fonts: {
title: { source: "google", name: "Space Grotesk", weights: [400, 600, 700] },
body: { source: "custom", url: "https://example.com/font.woff2", format: "woff2" },
},
theme: {
"font-title": "'Space Grotesk', sans-serif",
"font-body": "'Custom Font', sans-serif",
},
});Schema Builder
The x schema builder provides a type-safe, fluent API for defining your CSV structure.
For apps that share schema definitions with backend code, prefer defining the schema in @expresscsv/schemas and importing it into your frontend. That keeps schema authoring free of importer/runtime dependencies.
Field Types
import { x } from "@expresscsv/sdk";
const schema = x.row({
// Strings with validation
name: x.string().label("Full Name").min(2).max(100),
email: x.string().email().label("Email"),
website: x.string().url().label("Website").optional(),
// Numbers with constraints
age: x.number().label("Age").min(0).max(150).integer(),
salary: x.number().currency("USD").label("Salary").min(0),
// Boolean
isActive: x.boolean().label("Active"),
// Dates and times
startDate: x.date().label("Start Date"),
createdAt: x.datetime().label("Created At"),
// Single selection (requires { label, value } objects)
role: x.select([
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
{ label: "Viewer", value: "viewer" },
]).label("Role"),
// Multi-selection
tags: x.multiselect([
{ label: "Engineering", value: "eng" },
{ label: "Design", value: "design" },
{ label: "Marketing", value: "mkt" },
]).label("Tags"),
});Runtime-Built Schemas
For a fixed schema, keep everything in a normal x.row(...) definition:
const schema = x.row({
email: x.string().email().label("Email"),
lifecycleStage: x
.select([
{ label: "Lead", value: "lead" },
{ label: "Customer", value: "customer" },
{ label: "Churned", value: "churned" },
])
.label("Lifecycle Stage"),
accountOwner: x.string().label("Account Owner").optional(),
contractValue: x.number().currency("USD").label("Contract Value").optional(),
});x.row(...) also works for runtime-built schemas, which is useful when the shape depends on account data, user preferences, or enabled custom fields.
import { x } from "@expresscsv/sdk";
function buildCustomerSchema(options: {
collectCrmId: boolean;
collectHealthScore: boolean;
collectSegment: boolean;
}) {
return x.row({
email: x.string().email().label("Email"),
companyName: x.string().label("Company Name"),
...(options.collectCrmId ? { crmId: x.string().label("CRM ID") } : {}),
...(options.collectHealthScore
? { healthScore: x.number().label("Health Score") }
: {}),
...(options.collectSegment
? {
segment: x
.select([
{ label: "SMB", value: "smb" },
{ label: "Mid-Market", value: "mid-market" },
{ label: "Enterprise", value: "enterprise" },
])
.label("Segment"),
}
: {}),
});
}
const schema = buildCustomerSchema({
collectCrmId: true,
collectHealthScore: true,
collectSegment: false,
});Dynamic schema assembly preserves the same runtime parsing behavior, but it can widen Infer<typeof schema> because the exact keys are no longer fully known to TypeScript. Use it intentionally.
Common Modifiers
All field types support:
| Modifier | Description |
|---|---|
| .label(text) | User-facing label shown in the importer |
| .description(text) | Help text for the field |
| .optional() | Makes the field optional (default is required) |
| .refine(fn) | Custom validation function |
String Modifiers
| Modifier | Description |
|---|---|
| .email() | Validates email format |
| .url() | Validates URL format |
| .uuid() | Validates UUID format |
| .ip() | Validates IP address |
| .phone() | Validates phone number |
| .regex(pattern) | Matches a regular expression |
| .min(n) | Minimum string length |
| .max(n) | Maximum string length |
| .length(n) | Exact string length |
| .includes(str) | Must contain substring |
| .startsWith(str) | Must start with prefix |
| .endsWith(str) | Must end with suffix |
Number Modifiers
| Modifier | Description |
|---|---|
| .min(n) | Minimum value |
| .max(n) | Maximum value |
| .integer() | Must be a whole number |
| .multipleOf(n) | Must be a multiple of n |
| .currency(code) | Formats as currency (e.g. "USD") |
| .percentage() | Formats as percentage |
Custom Validation
.refine() and .refineBatch() let you validate field values with any JavaScript or TypeScript function — synchronous or async. They work on every field type.
.refine(validator, params?)
Validates each cell value individually. Three styles are supported:
Boolean validator — return true/false with a message in params:
x.string().refine(
(value) => value.startsWith("ACC-"),
{ message: 'Must start with "ACC-"' }
)Object-returning validator — inline valid, message, and an optional suggestedFix the importer can offer the user:
x.string().refine((value) => ({
valid: value === value.toUpperCase(),
message: "Must be uppercase",
suggestedFix: {
id: "to-uppercase",
value: value.toUpperCase(),
description: "Convert to uppercase",
},
}))RegExp — shorthand for pattern matching:
x.string().refine(/^[A-Z]{2}-\d{4}$/, { message: "Format must be XX-1234" })Async validator — useful for database lookups or API calls:
x.string().refine(async (value) => {
const exists = await checkDatabaseId(value);
return { valid: exists, message: `ID "${value}" not found` };
}).refineBatch(validator, params?)
Validates all values in the column at once — ideal for cross-row logic like bulk API lookups or comparisons across rows in the same batch. The function receives an array of every value in the column and must return a result array of the same length.
Async bulk lookup:
x.string().refineBatch(async (values) => {
const found = await bulkCheckExists(values);
return found.map((exists, i) => ({
valid: exists,
message: exists ? undefined : `Not found: "${values[i]}"`,
}));
})Result items can include a suggestedFix too:
x.string().refineBatch((values) => {
return values.map((value) => {
const trimmed = value.trim();
if (trimmed === value) return { valid: true };
return {
valid: false,
message: "Value has leading or trailing whitespace",
suggestedFix: {
id: "trim-whitespace",
value: trimmed,
description: "Remove whitespace",
},
};
});
})suggestedFix
Both .refine() and .refineBatch() support returning a suggestedFix object that the importer offers as a one-click fix for the user:
interface SuggestedFix {
id: string; // Unique identifier for the fix type
value: unknown; // The corrected value to apply
description: string; // Short label shown to the user (e.g. "Convert to uppercase")
}API Reference
CSVImporter
Constructor
new CSVImporter(options: SDKOptions)| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| schema | Schema | Yes | - | Schema definition created with x.row() |
| getAuthToken | () => Promise<string> | Yes | - | Async callback that asks your backend for a short-lived importer auth token |
| importNamespace | string | Yes | - | Stable namespace string your app assigns to this importer configuration. Keep it the same for the same workflow; use a different value for different importers. |
| title | string | No | - | Title shown in the importer header |
| preload | boolean | No | true | Preload the importer for instant display |
| debug | boolean | No | false | Enable debug logging |
| theme | Theme | No | - | Custom theme configuration |
| colorMode | ColorModePref | No | - | Light/dark mode ('light', 'dark', or 'system') |
| customCSS | string | No | - | Custom CSS to inject into the importer |
| fonts | Record<string, FontSource> | No | - | Custom font sources |
| stepDisplay | 'progressBar' \| 'segmented' \| 'numbered' | No | 'progressBar' | Step indicator style |
| previewSchemaBeforeUpload | boolean | No | true | Show schema preview before upload |
| columnMatching | { type: "managed"; exact?: boolean; caseInsensitive?: boolean; normalized?: boolean; inference?: boolean } \| { type: "custom"; columnMatchHandler: (...) => Promise<...> } | No | undefined | Configure managed column matching or provide a custom matcher |
| promptedEdits | { type: "managed" } \| { type: "custom"; promptedEditHandler: (...) => Promise<...> } | No | undefined | Enable managed prompted edits or provide a custom edit handler |
| templateDownload | TemplateDownloadOptions<TSchema> | No | - | Template download configuration with optional schema-typed example rows |
| sessionRecovery | SessionRecoveryOptions | No | - | Enable Recovered sessions with the built-in local backend or a custom adapter implementing get, set, and remove |
| locale | DeepPartial<ExpressCSVLocaleInput> | No | - | Localization overrides |
| disableStatusStep | boolean | No | - | Skip the success/error status screen |
open(options)
Opens the importer.
importer.open(options: OpenOptions): void| Option | Type | Required | Description |
|---|---|---|---|
| onData | (chunk: RecordsChunk<T>, next: () => void) => void | Yes | Callback for each delivered chunk of records. Call next() after your backend accepts the current chunk. |
| chunkSize | ChunkSize | No | Delivery packet size. Defaults to { unit: "kb", value: 500 }. kb uses decimal kilobytes (1 KB = 1000 bytes). Use { unit: "kb", value: 500 } for KB or { unit: "rows", value: 500 } for row counts. Zero or negative values send all records in one chunk. |
| onComplete | (context: { sessionId: string }) => void | No | Called when all chunks have been processed |
| onCancel | (context: { sessionId: string }) => void | No | Called when the user cancels the import |
| onError | (error: Error, context: { sessionId: string }) => void | No | Called when an error occurs |
| onOpen | (context: { sessionId: string }) => void | No | Called when the importer opens |
| onStepChange | (stepId, previousStepId?) => void | No | Called when the importer step changes |
close(reason?)
Closes the importer and cleans up resources.
await importer.close(reason?: 'user_close' | 'cancel' | 'complete' | 'error'): Promise<void>Status Methods
| Method | Returns | Description |
|---|---|---|
| getStatus() | ImporterStatus | Current importer lifecycle status |
| getIsReady() | boolean | Whether the importer is ready or open |
| getIsOpen() | boolean | Whether the importer is currently open |
| getConnectionStatus() | boolean | Whether the iframe connection is active |
| getCanRestart() | boolean | Whether the importer can be restarted |
| getLastError() | Error \| null | Last error, if any |
| getStatusSnapshot() | object | Comprehensive status snapshot |
restart(newOptions?)
Restarts the importer, optionally with updated options. Returns Promise<void>.
RecordsChunk<T>
The object passed to onData callbacks:
interface RecordsChunk<T> {
records: T[]; // Automatically typed to your schema
totalChunks: number;
chunkIndex: number;
totalRecords: number;
sessionId: string;
deliveryId: string;
}TypeScript
The SDK is written in TypeScript and provides full type inference from your schema:
import { CSVImporter, x, type Infer } from "@expresscsv/sdk";
const schema = x.row({
name: x.string(),
age: x.number(),
email: x.string().email().optional(),
});
type Row = Infer<typeof schema>;
// { name: string; age: number; email?: string }
const importer = new CSVImporter({
schema,
getAuthToken: async () => fetchAuthToken(),
importNamespace: "user-import",
});
importer.open({
onData: (chunk, next) => {
// chunk.records is fully typed as Row[]
for (const row of chunk.records) {
console.log(row.name); // string
console.log(row.age); // number
console.log(row.email); // string | undefined
}
next();
},
});Resources
- ExpressCSV Dashboard -- manage your imports and API keys
@expresscsv/react-- React hook wrapper
License
MIT
