How to Import a CSV File Into a Vue App With the Updog Importer
This guide shows how to add a CSV and Excel importer to a Vue app. Import takes more than a parser. Read CSV import is more than parsing a file for the reasons.
The example is an employee importer with seven columns. You install the package, tell Vue about the element, describe your data, match the file to your schema, and read the result.
Two words in this guide mean two different things. Upload is when someone puts a file into the data editor. The file is read in the browser, and nothing has reached you yet. Import is the last step, when the cleaned rows land in your system. Everything between the two happens inside the editor, and that is where the messy data gets fixed.
Updog comes to Vue as a Web Component. You register the custom element once, then use it in templates like any other tag. The same package works in Angular, Svelte, and plain JavaScript. React has its own package.
Step 1. Install the web component
npm install @updog/data-editor-wcThe package is on npm as @updog/data-editor-wc. Two imports register it. The module defines <updog-editor> globally. The stylesheet adds the styles. You need both, and you import them once for the whole app.
Step 2. Tell Vue it is a custom element
Vue treats every unknown tag as a component. It looks for a component named updog-editor, finds nothing, and renders nothing. The console shows Failed to resolve component: updog-editor. One compiler option fixes this.
import { defineConfig } from "vite";import vue from "@vitejs/plugin-vue";
export default defineConfig({ plugins: [ vue({ template: { compilerOptions: { isCustomElement: (tag) => { return tag.startsWith("updog-"); }, }, }, }), ],});This is the only Vue-specific setup in the guide.
Step 3. Mount the editor
Put the element in your template. Get a reference to it, then pass your configuration to configure(). In this example the editor sits behind a button. Modal is the default mode, so show() opens the editor. The close event fires when the person closes it.
<script setup lang="ts">import { useTemplateRef, watchEffect } from "vue";import "@updog/data-editor-wc";import "@updog/data-editor-wc/styles.css";import type { UpdogEditorElement } from "@updog/data-editor-wc";import { columns } from "./columns";
const editorElement = useTemplateRef<UpdogEditorElement>("editor");
watchEffect((onCleanup) => { const element = editorElement.value;
if (!element) { return; }
element.configure({ apiKey: "your-license-key", columns, primaryKey: "email", onComplete: (result) => { console.log(result); element.hide(); }, });
const handleClose = () => { element.hide(); };
element.addEventListener("close", handleClose);
onCleanup(() => { element.removeEventListener("close", handleClose); });});
function open() { editorElement.value?.show();}</script>
<template> <button type="button" @click="open">Import employees</button> <updog-editor ref="editor" variant="uploader" /></template>Set variant to uploader to open the upload wizard first, so the person starts by picking a file. The default editor variant opens the grid first, for data you already have. Sign up at console.updog.tech to get the apiKey. Updog is free on localhost and on the preview domains from hosts like Vercel and Netlify.
Step 4. Pass props the right way
The element takes two kinds of input. You set them in two different places.
Simple values can be attributes
Eight props are primitives, so they work as plain HTML attributes. Five take a string value, api-key, primary-key, variant, mode, and locale. Three are boolean, open, rtl, and readonly, where the presence of the attribute means true.
<updog-editor ref="editor" api-key="your-license-key" primary-key="email" variant="uploader"/>open controls the modal. Setting it does the same as calling show() and hide(), which set and remove that attribute internally. Bind :open to your own ref to keep the state in Vue instead.
Inline mode replaces the modal
mode picks where the editor renders. The default is modal. Use mode="inline" to render the editor in place, as part of your page.
<updog-editor ref="editor" variant="uploader" mode="inline" />Inline mode has no modal, so the modal API does nothing. show() and hide() return without an effect, and the element sends no close event. Remove the button and the listener from step 3, then give the element the space it should fill.
Everything else goes through configure
Columns, callbacks, translations, remote sources, and every other object or function go through configure(). It takes any number of props at once and applies them in one render.
Never write a callback as a template binding. Vue reads anything that starts with on as an event listener, so :onComplete registers a listener for an event that never fires. Your callback never runs, and you get no warning.
// wrong: Vue reads this as an event listener and never sets the prop<updog-editor :onComplete="save" />
// rightelement.configure({ onComplete: save });The same applies to onComplete, onError, onColumnMatch, and onValueMatch. Angular refuses to compile that line, and Svelte fails as silently as Vue does. Keep the habit in every framework.
Closing is an event
There is no onClose prop. The element sends a real close event. The example above listens for it and removes the listener in onCleanup. In a template, @close works too.
Step 5. Describe your data
The columns array describes the shape your app expects. Updog changes every uploaded file to match it. More detail here means less manual fixing later.
Start with the columns
Each column needs an id and a title. The id matches the key in your row data. The title is the text people see. size is optional. Add it in pixels to make a column wider or narrower.
import type { DataEditorColumn } from "@updog/data-editor-wc";
export const columns: DataEditorColumn[] = [ { id: "firstName", title: "First name", size: 150, }, { id: "lastName", title: "Last name", size: 150, }, { id: "email", title: "Email", size: 260, }, { id: "startDate", title: "Start date", size: 140, }, { id: "salary", title: "Salary", size: 120, }, { id: "skills", title: "Skills", size: 220, }, { id: "status", title: "Status", size: 140, },];Choose an editor for each column
Text is the default, so the name and email columns need nothing. Use date for the start date, number for salary, multiselect for skills, and select for status. An editor sets what people can type. For select and multiselect it also gives the matching step a list of values to match against.
{ id: "startDate", title: "Start date", size: 140, editor: { type: "date", },},{ id: "salary", title: "Salary", size: 120, editor: { type: "number", decimalPlaces: 0, },},{ id: "skills", title: "Skills", size: 220, editor: { type: "multiselect", options: ["React", "TypeScript", "Node", "Design"], },},{ id: "status", title: "Status", size: 140, editor: { type: "select", options: ["Active", "Onboarding", "On leave", "Terminated"], },},Add the rules
Validators run on every edit. The grid highlights a value that fails. Here is the full schema, with editors and validators together.
const STATUSES = ["Active", "Onboarding", "On leave", "Terminated"];
export const columns: DataEditorColumn[] = [ { id: "firstName", title: "First name", size: 150, validators: [{ type: "required", message: "First name is required" }], }, { id: "lastName", title: "Last name", size: 150, validators: [{ type: "required", message: "Last name is required" }], }, { id: "email", title: "Email", size: 260, validators: [ { type: "required", message: "Email is required" }, { type: "email", message: "Enter a valid email address" }, { type: "unique" }, ], }, { id: "startDate", title: "Start date", size: 140, editor: { type: "date" }, validators: [{ type: "date", message: "Enter a valid date" }], }, { id: "salary", title: "Salary", size: 120, editor: { type: "number", decimalPlaces: 0 }, validators: [ { type: "range", min: 0, message: "Salary must be zero or greater" }, ], }, { id: "skills", title: "Skills", size: 220, editor: { type: "multiselect", options: ["React", "TypeScript", "Node", "Design"], }, }, { id: "status", title: "Status", size: 140, editor: { type: "select", options: STATUSES }, validators: [ { type: "oneOf", values: STATUSES, message: "Select a status from the list", }, ], },];Email uses required and email. The unique validator flags a duplicate address in the file. Salary uses a range check that rejects anything below zero. Status uses oneOf against the same list its dropdown offers, which catches a value that slips past matching. A failed rule marks the cell. It does not block submission by default. Invalid rows still reach you with the isValid flag. Set blockSubmitOnError to hold submission until every error is fixed.
Validators come in three kinds, and the schema above uses only the first. Built-in rules are declarative objects, and there are eight of them, required, regex, oneOf, range, email, date, numeric, and unique.
For logic no built-in covers, a function rule takes your own function. It receives the cell value and the whole row, and returns an error or null. Pair it with dependentFields to revalidate a column when another column changes.
For a check against your own backend, an asyncFunction rule receives every affected cell in one batch and reports failures as they arrive. Async rules run after all the sync ones, so a value reaches your server only after it passes the format checks. The columns reference covers all three.
Fix common errors before anyone sees them
A validator flags a bad value. A transformer fixes it first. Add one to a column, and Updog runs it on each cell as the uploaded file is turned into rows, before anyone sees the grid. Use it to trim spaces, lowercase an email, or drop a currency symbol.
{ id: "email", title: "Email", size: 260, transformer: (value) => { return String(value).trim().toLowerCase(); },},Shape how columns show and filter
Two more column props work on the grid. A formatter changes how a value looks, and the stored value stays the same. Salary can show a $ while the number underneath stays clean. A filter adds a control to the sidebar.
{ id: "salary", title: "Salary", size: 120, formatter: (value) => { return value ? "$" + value : ""; }, filter: { type: "number-range", label: "Salary", },},Step 6. Help Updog match the file
The schema is ready. The file rarely matches it word for word. Updog runs fuzzy matching first and catches small differences on its own, like extra spaces, different capitalization, or a common alternate spelling. Your data may also use terms it cannot guess, such as an internal code or a word from your industry. For those you add your own dictionary.
Match the columns
The file might say E-mail and Employment Status where your columns are email and status. Built-in matching pairs the obvious ones. List the headers your users send in the columns table of synonyms, and they map on their own.
element.configure({ synonyms: { columns: { email: ["e-mail", "email address", "work email"], status: ["employment status", "employee status", "state"], }, },});For a header no list catches, pass the decision to your own model through onColumnMatch. Updog gives it the file headers and your columns. You return a map of which goes where.
element.configure({ onColumnMatch: async (headers, columns) => { // headers = ["Emp Email", "Emp Status", ...] from the file // columns = your firstName, lastName, email, ... schema const map = await askYourModel(headers, columns); return map; // { "Emp Email": "email", "Emp Status": "status" } },});See bring your own AI to CSV and Excel import for that path and how the data stays private.
Match the values
The same works inside the status column. A file can send loa and leave for the status On leave. Value aliases go in the values table, keyed by the option, with the array listing what maps to it. The two tables stay apart, so a word learned inside a dropdown never competes for a column.
One object holds both tables, so write it once. A second configure() call with a new synonyms object replaces the first one, and your column aliases disappear without a warning.
element.configure({ synonyms: { columns: { email: ["e-mail", "email address", "work email"], status: ["employment status", "employee status", "state"], }, values: { "On leave": ["loa", "on-leave", "leave"], Terminated: ["term", "left"], }, },});For values no list covers, onValueMatch works the same way one level down. Updog gives you the values found in each select column and the options you allow. You return the mapping.
element.configure({ onValueMatch: async (valuesToMatch) => { // valuesToMatch = { status: { importedValues: ["loa", "left"], options: [...] } } const map = await askYourModel(valuesToMatch); return map; // { status: { loa: "On leave", left: "Terminated" } } },});Step 7. Import the result into your system
This is the import. Everything before it happened in the browser, and nothing has left the page yet.
When the person is done cleaning the data, Updog passes it to onComplete, grouped by source. Each row carries four flags, isNew, isChanged, isDeleted, and isValid. Routing depends on your backend, so Updog leaves it to you. Filter on the flags and send each row where it belongs.
import type { DataEditorResult } from "@updog/data-editor-wc";
element.configure({ onComplete: async (result: DataEditorResult) => { for (const source of result.sources) { const inserts = source.rows.filter((row) => { return row.isNew && !row.isDeleted && row.isValid; });
const updates = source.rows.filter((row) => { return !row.isNew && row.isChanged && !row.isDeleted && row.isValid; });
await saveEmployees(inserts, updates); }
element.hide(); },});Rows arrive keyed by your column IDs, typed as Record<string, unknown>. The custom element takes no generic parameter. Cast to your own row type here if you want one.
Step 8. Localize it
Three props control language and layout, and each does one job.
element.configure({ translations: arabicTranslations, // changes the language locale: "ar", // picks Arabic plural forms rtl: true, // flips the layout});translations is the prop that changes the language. Pass an object with the strings you override. Only the keys you provide are replaced.
locale takes a BCP 47 tag and selects the plural form for keys that end in _one, _few, _many, or _other. That is its only job. It loads no translations, and on its own it leaves the UI in English. Set it when your language pluralizes differently from English. Arabic has six forms, Russian has three.
rtl flips the layout for Arabic, Hebrew, and other right-to-left languages. It changes grid direction, text alignment, and scrollbar position, and it reverses the column order in exported files.
You do not have to translate every string by hand. Copy the defaults object from the localization docs, paste it into your AI, and ask it to translate the values.
Style it to match your app
The editor renders in the light DOM, so your CSS can reach it. This has one consequence in Vue. A scoped style block does not apply inside the editor. Vue marks the elements it renders with a data attribute, and the editor builds its own DOM without one. Use :deep() and the rule applies.
<style scoped>/* Does not reach inside the editor. */.updog-button { border-radius: 0;}
/* Does. */.host :deep(.updog-button) { border-radius: 0;}</style>Colors come from CSS variables. Fonts come from your page. If your app sets no font, the editor uses the browser default, so set one on the element or on your app.
updog-editor { font-family: "Inter", system-ui, sans-serif; --updog-grid-cell-bg-idle: #ffffff; --updog-grid-header-bg-idle: #f8f9fa;}For the full variable list, see the styling reference. There is a longer walkthrough in style Updog to match your product.
What it costs, and how to pay less
The web component carries its own copy of React and the engine that reads Excel files. Here is the same Vue app built three ways, measured after Brotli compression.
| Build | First load |
|---|---|
| Vue on its own | 21 KB |
| Updog loaded at the top of a component | 460 KB |
| Updog loaded inside the click handler | 22 KB |
React accounts for about a tenth of that. The spreadsheet engine takes most of the rest, and the single-file build cannot split it out.
The weight buys one thing. The editor runs inside your page as a single custom element. The usual alternative is an iframe, where the code lives somewhere else and the cost moves instead of going away. Your CSS and your fonts stop at the frame boundary. The frame clips dropdowns and modals instead of letting them float over your layout. Focus and scrolling need their own handling. Every row of data crosses a postMessage protocol, and you maintain that protocol.
An importer opens when someone clicks a button, so it does not belong in your first bundle. Move the module import into the click handler, as in the third row above. Then only the person who opens the importer downloads it.
<script setup lang="ts">import { nextTick, ref, useTemplateRef } from "vue";import type { UpdogEditorElement } from "@updog/data-editor-wc";import { columns } from "./columns";
const ready = ref(false);const editorElement = useTemplateRef<UpdogEditorElement>("editor");
let setupPromise: Promise<void> | null = null;
function setupEditor() { if (!setupPromise) { setupPromise = (async () => { await import("@updog/data-editor-wc"); await import("@updog/data-editor-wc/styles.css"); ready.value = true;
await nextTick(); await customElements.whenDefined("updog-editor");
const element = editorElement.value;
if (!element) { return; }
element.configure({ apiKey: "your-license-key", columns, primaryKey: "email", onComplete: (result) => { console.log(result); element.hide(); }, });
element.addEventListener("close", () => { element.hide(); }); })(); }
return setupPromise;}
async function open() { await setupEditor(); editorElement.value?.show();}</script>
<template> <button type="button" @click="open">Import employees</button> <updog-editor v-if="ready" ref="editor" variant="uploader" /></template>Both nextTick and whenDefined matter here. The reference does not exist until Vue has rendered the element, and configure() has to run after the browser upgrades it. The total download grows by about a kilobyte this way. The bytes move off the first load.