Back to all postsA red paper cutout of the Angular shield with a dog face on cream paper

How to Import a CSV File Into an Angular App With the Updog Importer

An Angular app takes CSV and Excel files from its users with one custom element and a column schema. Import takes more than a parser. Read CSV import is more than parsing a file for the reasons.

You install the package, tell Angular about the element, describe your data, match the file to your schema, and read the result. The data is a product catalog with eight columns.

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 Angular as a Web Component. You register the custom element once, then use it in templates like any other tag. The same package works in Vue, Svelte, and plain JavaScript. React has its own package. We measured every build number and reproduced every compiler message on Angular 21.2 with the @angular/build application builder, in August 2026.

Step 1. Install the web component

npm install @updog/data-editor-wc

The package is on npm as @updog/data-editor-wc. The module defines <updog-editor> globally. The stylesheet adds the styles. You need both.

Step 2. Tell Angular it is a custom element

Angular resolves every tag in a template against the components you imported. It finds nothing named updog-editor and stops the build.

NG8001: 'updog-editor' is not a known element:
1. If 'updog-editor' is an Angular component, then verify that it is
included in the '@Component.imports' of this component.
2. If 'updog-editor' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA'
to the '@Component.schemas' of this component to suppress this message.

Add CUSTOM_ELEMENTS_SCHEMA to the schemas array of the component that renders the element, and Angular passes unknown tags through to the browser, where the custom element is waiting.

The stylesheet goes in the styles array in angular.json, ahead of your own global sheet, so your rules win where they overlap.

{
"projects": {
"catalog": {
"architect": {
"build": {
"options": {
"styles": [
"@updog/data-editor-wc/styles.css",
"src/styles.css"
]
}
}
}
}
}
}

The stylesheet is registered once for the app. The schema goes on every component that renders the element.

Step 3. Mount the editor

Put the element in your template, grab it with viewChild, and pass your configuration to configure(). The editor sits behind a button. Modal is the default mode, so show() opens the editor.

import {
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
ElementRef,
effect,
viewChild,
} from "@angular/core";
import "@updog/data-editor-wc";
import type {
DataEditorResult,
UpdogEditorElement,
} from "@updog/data-editor-wc";
import { columns } from "./columns";
@Component({
selector: "app-importer",
changeDetection: ChangeDetectionStrategy.OnPush,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<button type="button" (click)="open()">Import products</button>
<updog-editor
#editor
variant="uploader"
(close)="editor.hide()"
></updog-editor>
`,
})
export class ImporterComponent {
private readonly editorRef =
viewChild.required<ElementRef<UpdogEditorElement>>("editor");
constructor() {
effect(() => {
this.editorRef().nativeElement.configure({
apiKey: "your-license-key",
columns,
primaryKey: "sku",
onComplete: (result: DataEditorResult) => {
console.log(result);
this.editorRef().nativeElement.hide();
},
});
});
}
protected open(): void {
this.editorRef().nativeElement.show();
}
}

The element exists once the view has rendered, and an effect runs after that, then again whenever a signal it reads changes. Configuration that comes from a signal input stays in sync on its own.

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

An HTML attribute carries a string. Everything the editor needs beyond strings and booleans travels as a JavaScript property.

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
#editor
api-key="your-license-key"
primary-key="sku"
variant="uploader"
></updog-editor>

Angular property binding reaches the same values under their camelCase names. open controls the modal, and setting it does the same work as show() and hide(). Bind it to a signal and the modal state lives with the rest of your component state.

<updog-editor #editor variant="uploader" [open]="isOpen()"></updog-editor>

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 #editor variant="uploader" mode="inline"></updog-editor>

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.

Callbacks have no template form. Angular reserves property names that start with on for DOM event handlers, and it refuses to bind one.

<!-- wrong: the build stops here -->
<updog-editor [onComplete]="save"></updog-editor>
NG8002: Binding to event property 'onComplete' is disallowed for
security reasons, please use (Complete)=...

The suggested (Complete) event does not exist, so ignore that half of the message and move the callback into configure(), along with onError, onColumnMatch, and onValueMatch.

Closing is an event

There is no onClose prop. The element sends a real close event, which Angular binds like any other. A template reference variable on the element points at the element itself, so (close)="editor.hide()" in step 3 closes the modal without a listener to register or remove.

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: "sku",
title: "SKU",
size: 140,
},
{
id: "name",
title: "Product name",
size: 240,
},
{
id: "category",
title: "Category",
size: 160,
},
{
id: "price",
title: "Price",
size: 120,
},
{
id: "stock",
title: "In stock",
size: 120,
},
{
id: "releasedOn",
title: "Released on",
size: 140,
},
{
id: "tags",
title: "Tags",
size: 220,
},
{
id: "status",
title: "Status",
size: 140,
},
];

Choose an editor for each column

Text is the default, so the SKU and name columns need nothing. Use select for the category, number for price and stock, date for the release date, and multiselect for tags. 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: "category",
title: "Category",
size: 160,
editor: {
type: "select",
options: ["Laptops", "Monitors", "Keyboards", "Cables"],
},
},
{
id: "price",
title: "Price",
size: 120,
editor: {
type: "number",
decimalPlaces: 2,
},
},
{
id: "releasedOn",
title: "Released on",
size: 140,
editor: {
type: "date",
},
},
{
id: "tags",
title: "Tags",
size: 220,
editor: {
type: "multiselect",
options: ["Sale", "New", "Refurbished", "Bundle"],
},
},

Add the rules

Validators run on every edit. The grid highlights a value that fails. The full schema puts editors and validators on the same columns.

const STATUSES = ["Draft", "Active", "Discontinued"];
export const columns: DataEditorColumn[] = [
{
id: "sku",
title: "SKU",
size: 140,
validators: [
{ type: "required", message: "SKU is required" },
{ type: "unique" },
],
},
{
id: "name",
title: "Product name",
size: 240,
validators: [{ type: "required", message: "Product name is required" }],
},
{
id: "category",
title: "Category",
size: 160,
editor: {
type: "select",
options: ["Laptops", "Monitors", "Keyboards", "Cables"],
},
},
{
id: "price",
title: "Price",
size: 120,
editor: { type: "number", decimalPlaces: 2 },
validators: [
{ type: "range", min: 0, message: "Price must be zero or greater" },
],
},
{
id: "stock",
title: "In stock",
size: 120,
editor: { type: "number", decimalPlaces: 0 },
},
{
id: "releasedOn",
title: "Released on",
size: 140,
editor: { type: "date" },
validators: [{ type: "date", message: "Enter a valid date" }],
},
{
id: "tags",
title: "Tags",
size: 220,
editor: {
type: "multiselect",
options: ["Sale", "New", "Refurbished", "Bundle"],
},
},
{
id: "status",
title: "Status",
size: 140,
editor: { type: "select", options: STATUSES },
validators: [
{
type: "oneOf",
values: STATUSES,
message: "Select a status from the list",
},
],
},
];

SKU uses required and unique, which flags the second row that repeats a code. Price 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.

Those are all built-in rules, declarative objects that Updog ships with. There are eight, 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. A discount price that has to stay under the list price needs one.

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, upper-case a product code, or drop a currency symbol.

{
id: "sku",
title: "SKU",
size: 140,
transformer: (value) => {
return String(value).trim().toUpperCase();
},
},

Shape how columns show and filter

A formatter changes how a value looks on the grid, and the stored value stays the same. Price can show a $ while the number underneath stays clean. A filter adds a control to the sidebar.

{
id: "price",
title: "Price",
size: 120,
formatter: (value) => {
return value ? "$" + value : "";
},
filter: {
type: "number-range",
label: "Price",
},
},

Step 6. Help Updog match the file

A file rarely matches your schema 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

A warehouse export of this catalog arrived with the headers Item Code, Title, Group, Cost, Qty, Launch, Labels, and State. Built-in matching paired one of the eight. List the headers your users send in the columns table of synonyms, and the same file arrives fully mapped.

this.editorRef().nativeElement.configure({
synonyms: {
columns: {
sku: ["item code", "article number", "part no"],
name: ["title", "product title", "item name"],
category: ["group", "product group", "product line"],
price: ["cost", "unit price", "list price"],
stock: ["qty", "quantity", "on hand"],
releasedOn: ["launch", "launch date", "release date"],
tags: ["labels"],
status: ["state", "product status"],
},
},
});

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.

this.editorRef().nativeElement.configure({
onColumnMatch: async (headers, columns) => {
// headers = ["Item Code", "Group", ...] from the file
// columns = your sku, name, category, ... schema
const map = await askYourModel(headers, columns);
return map; // { "Item Code": "sku", Group: "category" }
},
});

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 eol and retired for the status Discontinued. 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.

this.editorRef().nativeElement.configure({
synonyms: {
columns: {
sku: ["item code", "article number", "part no"],
status: ["state", "product status"],
},
values: {
Discontinued: ["eol", "end of life", "retired"],
Refurbished: ["refurb", "renewed"],
},
},
});

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.

this.editorRef().nativeElement.configure({
onValueMatch: async (valuesToMatch) => {
// { status: { importedValues: ["eol"], options: [...] } }
const map = await askYourModel(valuesToMatch);
return map; // { status: { eol: "Discontinued" } }
},
});

Step 7. Import the result into your system

Everything before this step 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";
export class ImporterComponent {
protected readonly imported = signal(0);
constructor() {
effect(() => {
this.editorRef().nativeElement.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 saveProducts(inserts, updates);
this.imported.update((count) => count + inserts.length);
}
this.editorRef().nativeElement.hide();
},
});
});
}
}

A modern Angular app renders when something notifies it, and a signal write, a template event binding, and an async pipe all do. The editor calls onComplete from its own render tree, outside every one of them. Assign the imported count to a plain field there and the field holds the new number while the screen keeps showing the old one. Write it into a signal and the view catches up. An output() that a parent template listens to works too, since Angular runs change detection around its own listeners.

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.

this.editorRef().nativeElement.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.

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. Angular rewrites .updog-button in a component stylesheet into .updog-button[_ngcontent-ng-c123456], and it stamps that attribute on the elements it renders itself. The editor builds its own DOM without one, so the rule matches nothing. The modal sits further out still, at the end of document.body, where even :host ::ng-deep stops at the component boundary and misses it.

Global styles reach everything. Put your editor rules in src/styles.css, next to the editor stylesheet you registered in step 2.

/* importer.component.css: never reaches the editor. */
.updog-button {
border-radius: 0;
}
/* src/styles.css: does. */
.updog-button {
border-radius: 0;
}

A selector that starts with ::ng-deep compiles down to that same global rule and works too, with the catch that it applies everywhere in the app from the moment the component loads.

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. The same Angular app weighs three different amounts, depending on where that import sits. Every number is after Brotli compression.

BuildFirst load
Angular on its own28 KB
Updog imported in the component500 KB
Updog behind @defer52 KB

A new Angular workspace sets a bundle budget of 500 kB for a warning and 1 MB for an error, counted before compression. The eager build weighs 2.11 MB there, so ng build reports a failure and writes nothing. Raising the budget clears the message and leaves every visitor downloading the importer.

React accounts for about a tenth of that weight. The spreadsheet engine takes most of the rest, and the single-file build cannot split it out.

The editor runs inside your page as a single custom element, and that is what the weight buys. 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. Wrap the component in @defer, and Angular moves it and its imports into a chunk that downloads on the first click.

import { ChangeDetectionStrategy, Component, signal } from "@angular/core";
import { ImporterComponent } from "./importer/importer.component";
@Component({
selector: "app-catalog",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ImporterComponent],
template: `
<button type="button" (click)="wanted.set(true)">
Import products
</button>
@defer (when wanted()) {
<app-importer />
}
`,
})
export class CatalogComponent {
protected readonly wanted = signal(false);
}

The importer component from step 3 stays as it is. The parent decides when it loads, the initial bundle drops to 52 KB, and the 453 KB chunk goes out to the people who ask for it. The development server hides this while you work, because hot module replacement loads every @defer dependency eagerly and logs NG0751 to say so. Run ng build to see the real split.

Angular on the server

The editor's module calls customElements.define the moment it loads, and an app with @angular/ssr loads your components in Node first, to walk the routes. A plain import brings the whole build down there.

An error occurred while extracting routes.
ReferenceError: customElements is not defined
at file:///.angular/prerender-root/main.server.mjs

@defer solves this too. Angular puts the deferred block behind a dynamic import that the server never reaches, route extraction finishes, and the page prerenders. The button ships in the HTML, and the editor arrives in the browser when someone clicks it.

A schema, a custom element, and a @defer block are the whole integration. The file the person picks stays in their browser the entire time, and what reaches you at the end is clean rows.