Express CSV Logo

Nuxt Quickstart

Use this pre-built prompt to get started faster with Nuxt.

Alternatively, follow these steps to manually add ExpressCSV to a Nuxt app and relay imports to Nitro server routes in your own backend.

  1. Install the SDK

    npm install @expresscsv/sdk
  2. On your backend: create a session endpoint 🔒

    Create a small backend route that:

    • Exchanges your ExpressCSV secret key for a short-lived session token
    • Is called from your frontend right before the importer opens

    For example, in a Hono backend:

    import { serve } from "@hono/node-server";
    import { Hono } from "hono";
    
    const app = new Hono();
    
    app.post("/your-api/import/session", async (c) => {
      // This route lives on your backend so your secret key never reaches the
      // browser.
      const response = await fetch("https://api.expresscsv.com/v1/importer/sessions", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.YOUR_EXPRESSCSV_SECRET_KEY}`,
        },
      });
    
      // Return only the short-lived token your frontend needs to open the
      // importer.
      const { token } = await response.json();
    
      return c.json({ token });
    });
    
    serve({
      fetch: app.fetch,
      port: 4000,
    });
  3. On your frontend: define your import schema

    Describe the columns you want to accept:

    • Field types control parsing and validation
    • Labels and rules shape the importer experience
    import { x } from "@expresscsv/sdk";
    
    export const productSchema = x.row({
      // Ask for the SKU in the format your catalog already uses.
      sku: x
        .string()
        .label("SKU")
        .refine(/^[A-Z]{3}-\d{4}$/, { message: "Format must be ABC-1234" }),
    
      // Require a product name so every imported row is clearly identified.
      name: x.string().min(1).label("Product Name"),
    
      // Parse prices as numbers up front so your backend receives clean data.
      price: x.number().min(0).currency("USD").label("Price"),
    
      // Map incoming category values to the controlled options your app
      // supports.
      categories: x
        .multiselect([
          { label: "Apparel", value: "apparel" },
          { label: "Electronics", value: "electronics" },
          { label: "Home Goods", value: "home-goods" },
        ])
        .min(1)
        .label("Category"),
    });
  4. On your frontend: open the importer

    The example below shows how to open the importer from the client and send validated rows in chunks.

    In Nuxt, keep the importer in a client-only component so it only runs in the browser:

    <script setup lang="ts">
    import { CSVImporter } from "@expresscsv/sdk";
    import { productSchema } from "./product-schema";
    
    // Create the importer only in the browser and point it at your schema.
    const importer =
      import.meta.client
        ? new CSVImporter({
            schema: productSchema,
            getSessionToken: async () => {
              // Ask your backend for a fresh session token right before the importer
              // opens.
              const response = await fetch("/your-api/import/session", {
                method: "POST",
                headers: {
                  Authorization: `Bearer ${accessToken}`,
                },
              });
              const { token } = await response.json();
              return token;
            },
            // Keep this stable for this importer configuration.
            importNamespace: "product-import",
            title: "Import products",
          })
        : null;
    
    function openImporter() {
      if (!importer) {
        return;
      }
    
      importer.open({
        chunkSize: { unit: "kb", value: 500 },
        // Open the importer when the user is ready to upload a file.
        onData: async (chunk, next) => {
          // POST this chunk to your API and upsert it in your import chunk staging
          // table (stage JSON, then write uploaded records to target tables: see
          // the Delivery doc).
          const response = await fetch("/your-api/import-products/chunks", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${accessToken}`,
            },
            body: JSON.stringify({
             // sessionId groups the import, deliveryId identifies this delivery
             // attempt,
             // and chunkIndex keeps each piece of the delivery in order.
              sessionId: chunk.sessionId,
              deliveryId: chunk.deliveryId,
              chunkIndex: chunk.chunkIndex,
              records: chunk.records,
            }),
          });
    
          if (!response.ok) {
            // Stop the import if your backend cannot accept this chunk.
            throw new Error("Backend rejected this import chunk");
          }
    
          // Call next() only after your API returned 2xx (row committed in your
          // import chunk staging table; retries stay safe).
          next();
        },
        onCancel: async ({ sessionId }) => {
          // Clean up any partial work if the user cancels.
          await fetch("/your-api/import-products/abort", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${accessToken}`,
            },
            body: JSON.stringify({ sessionId }),
          });
        },
       onError: async (error, context) => {
          console.error("Import error", error);
    
          // Clean up if something goes wrong during delivery.
          await fetch("/your-api/import-products/abort", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${accessToken}`,
            },
           body: JSON.stringify({
             sessionId: context.sessionId,
             deliveryId: context.deliveryId ?? null,
             message: error.message,
           }),
          });
        },
      });
    }
    </script>
    
    <template>
      <button type="button" @click="openImporter">
        Import Products
      </button>
    </template>
  5. Render the button from a page

    <script setup lang="ts">
    import ImportProductsButton from "~/components/ImportProductsButton.client.vue";
    </script>
    
    <template>
      <ImportProductsButton />
    </template>

Saving imported rows

How you save imported rows on the backend is up to you. A recommended pattern:

  • Add an import chunk staging table with sessionId, deliveryId, and chunkIndex columns plus a JSON column for the chunk payload (e.g. records), separate from your business tables:
import { integer, jsonb, pgTable, text, unique } from "drizzle-orm/pg-core";

// Staging table only. Do not write imported rows to your business tables here.
export const importChunkStaging = pgTable(
  "import_chunk_staging",
  {
    sessionId: text("session_id").notNull(),
    deliveryId: text("delivery_id").notNull(),
    chunkIndex: integer("chunk_index").notNull(),
    // Same array as chunk.records from onData.
    records: jsonb("records").notNull(),
  },
  (t) => [
    // One row per chunk. Retries overwrite the same row instead of duplicating.
    unique("import_chunk_staging_session_delivery_index_unique").on(
      t.sessionId,
      t.deliveryId,
      t.chunkIndex,
    ),
  ],
);
  • On each chunk POST, upsert a staging row keyed by (sessionId, deliveryId, chunkIndex) from the request body and store records in the JSON column. Retries update the same row instead of inserting a duplicate:
// Fields your onData handler POSTs for each chunk.
const { sessionId, deliveryId, chunkIndex, totalChunks, records } =
  await request.json();

// Save this chunk. Upsert so a retry replaces the same row.
await db.importChunkStaging.upsert({
  sessionId,
  deliveryId,
  chunkIndex,
  records,
});

// Last chunk for this delivery? Write all staged rows to your business tables.
if (chunkIndex + 1 === totalChunks) {
  await promoteAllStagedChunks({ sessionId, deliveryId });
}
  • Wait for the last chunk before writing to your business tables:
import { and, asc, eq } from "drizzle-orm";
import { type Infer } from "@expresscsv/schemas";
import { importedEmployeeSchema } from "./schemas/employee";
import { db, employees, importChunkStaging } from "./db";

// Row shape the importer already validated in the browser.
type ImportedEmployee = Infer<typeof importedEmployeeSchema>;

async function promoteAllStagedChunks({
  sessionId,
  deliveryId,
}: {
  sessionId: string;
  deliveryId: string;
}) {
  // Read every staged chunk for this delivery, in order.
  const staged = await db
    .select()
    .from(importChunkStaging)
    .where(and(eq(importChunkStaging.sessionId, sessionId), eq(importChunkStaging.deliveryId, deliveryId)))
    .orderBy(asc(importChunkStaging.chunkIndex));

  // Cast JSON to your schema, map importer fields to business columns, then insert.
  await db.insert(employees).values(
    staged.flatMap((c) =>
      (c.records as ImportedEmployee[]).map((r) => ({ fullName: r.name, workEmail: r.email })),
    ),
  );

  // It's a good idea to clean up staging rows after use.
}
  • onError: delete staged chunks for the deliveryId when delivery fails partway through.
  • onCancel: delete staged chunks for the sessionId when the user cancels.

See Delivery for the import chunk staging table, writing the uploaded records to your target table(s) from staged JSON, and idempotent chunk writes.

What's Next

  • Session tokens: keep your secret key on the backend and return fresh importer tokens
  • Delivery: store chunk JSON, write uploaded records to target tables when the ledger is complete, clean up aborted sessions
  • Types: define field types, validation rules, and inferred row types
  • Column matching: map messy CSV headers to your schema fields
  • Prompted edits: let users clean imported values with prompts