Free resources

A free CSV import component for Salesforce (LWC + Apex)

Let users upload a CSV, preview it, see problems before they import, and get a row-by-row result. Works with any object. Copy the code.

Free and open source (MIT license). The .zip is a Salesforce DX project ready to deploy.

Data Import Wizard is fine for admins. But when an operations team needs to load the same kind of file every week, they want a button on the page they already use, a preview of what is about to happen and a clear list of what failed.

This component does that. Drop it on any Lightning page, point it at an object, and users can:

  • Choose a CSV file from their computer
  • Preview the first 50 rows, with problems flagged before anything is saved
  • Import the valid rows and see which ones failed, and why

It has no dependencies: no static resources, no managed package. Everything below is free to use, change and ship in your org.

How it works

The browser does the reading and checking. The CSV is parsed in JavaScript, so the user gets instant feedback on missing columns or empty required values. Only the rows that pass are sent to Apex.

Apex does the saving. It converts each value to the right field type, respects field-level security with Security.stripInaccessible, and uses Database.insert with allOrNone = false so one bad row does not block the rest.

Column headers in the file must be field API names, for example FirstName, LastName, Email or My_Field__c. Columns that do not match a field are ignored.

The Apex controller

CsvImportController.cls
public with sharing class CsvImportController {
    private static final Integer MAX_ROWS = 2000;

    public class RowResult {
        @AuraEnabled public Integer rowNumber;
        @AuraEnabled public Boolean success = false;
        @AuraEnabled public String recordId;
        @AuraEnabled public String message;
    }

    @AuraEnabled
    public static List<RowResult> importRecords(String objectApiName, String rowsJson) {
        Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectApiName);
        if (objectType == null) {
            throw error('Unknown object: ' + objectApiName);
        }
        Schema.DescribeSObjectResult objectInfo = objectType.getDescribe();
        if (!objectInfo.isCreateable()) {
            throw error('You do not have permission to create ' + objectInfo.getLabelPlural() + '.');
        }
        Map<String, Schema.SObjectField> fields = objectInfo.fields.getMap();

        List<Object> rows = (List<Object>) JSON.deserializeUntyped(rowsJson);
        if (rows.size() > MAX_ROWS) {
            throw error('Import up to ' + MAX_ROWS + ' rows at a time.');
        }

        List<RowResult> results = new List<RowResult>();
        List<SObject> records = new List<SObject>();
        List<Integer> resultIndexes = new List<Integer>();

        for (Integer i = 0; i < rows.size(); i++) {
            Map<String, Object> row = (Map<String, Object>) rows[i];
            RowResult result = new RowResult();
            result.rowNumber = i + 1;
            results.add(result);

            SObject record = objectType.newSObject();
            try {
                for (String header : row.keySet()) {
                    Schema.SObjectField field = fields.get(header.trim());
                    if (field == null) {
                        continue; // not a field on this object
                    }
                    Schema.DescribeFieldResult fieldInfo = field.getDescribe();
                    if (!fieldInfo.isCreateable()) {
                        continue;
                    }
                    Object raw = row.get(header);
                    String value = raw == null ? null : String.valueOf(raw).trim();
                    record.put(field, convert(value, fieldInfo.getType()));
                }
                records.add(record);
                resultIndexes.add(results.size() - 1);
            } catch (Exception e) {
                result.message = 'Could not read a value: ' + e.getMessage();
            }
        }

        // Drop any field the running user cannot create, instead of failing.
        List<SObject> safeRecords = Security.stripInaccessible(AccessType.CREATABLE, records).getRecords();
        List<Database.SaveResult> saves = Database.insert(safeRecords, false);

        for (Integer i = 0; i < saves.size(); i++) {
            RowResult result = results[resultIndexes[i]];
            result.success = saves[i].isSuccess();
            if (result.success) {
                result.recordId = saves[i].getId();
            } else {
                result.message = saves[i].getErrors()[0].getMessage();
            }
        }
        return results;
    }

    private static Object convert(String value, Schema.DisplayType fieldType) {
        if (String.isBlank(value)) {
            return null;
        }
        switch on fieldType {
            when BOOLEAN {
                return value.equalsIgnoreCase('true') || value == '1' || value.equalsIgnoreCase('yes');
            }
            when INTEGER {
                return Integer.valueOf(value);
            }
            when LONG {
                return Long.valueOf(value);
            }
            when DOUBLE, CURRENCY, PERCENT {
                return Decimal.valueOf(value.replace(',', ''));
            }
            when DATE {
                return Date.valueOf(value); // yyyy-mm-dd
            }
            when DATETIME {
                return (Datetime) JSON.deserialize('"' + value + '"', Datetime.class); // ISO 8601
            }
            when else {
                return value;
            }
        }
    }

    private static AuraHandledException error(String message) {
        AuraHandledException e = new AuraHandledException(message);
        e.setMessage(message);
        return e;
    }
}

A few choices worth knowing about:

  • with sharing means users only touch what their sharing rules allow, and stripInaccessible covers field-level security.
  • Numbers drop thousands separators (1,250.50 becomes 1250.50). If your users write decimals with commas, change that line.
  • Dates must be yyyy-mm-dd and date-times ISO 8601. That avoids guessing whether 03/04 is March or April.

The test class

CsvImportControllerTest.cls
@IsTest
private class CsvImportControllerTest {
    @IsTest
    static void importsValidRowsAndReportsFailures() {
        String rowsJson = JSON.serialize(new List<Map<String, Object>>{
            new Map<String, Object>{ 'LastName' => 'Rivera', 'Email' => 'ana.rivera@example.com' },
            new Map<String, Object>{ 'LastName' => '', 'Email' => 'no.name@example.com' }
        });

        Test.startTest();
        List<CsvImportController.RowResult> results = CsvImportController.importRecords('Contact', rowsJson);
        Test.stopTest();

        Assert.areEqual(2, results.size());
        Assert.isTrue(results[0].success, 'First row should be saved');
        Assert.isFalse(results[1].success, 'LastName is required on Contact');
        Assert.isNotNull(results[1].message);
        Assert.areEqual(1, [SELECT COUNT() FROM Contact]);
    }

    @IsTest
    static void rejectsUnknownObject() {
        try {
            CsvImportController.importRecords('Not_A_Real_Object__c', '[]');
            Assert.fail('Expected an error for an unknown object');
        } catch (AuraHandledException e) {
            Assert.isTrue(e.getMessage().contains('Unknown object'));
        }
    }
}

The component

csvImporter.html
<template>
    <lightning-card title="CSV import" icon-name="doctype:csv">
        <div class="slds-p-horizontal_medium">
            <lightning-input
                type="file"
                label="Choose a CSV file"
                accept=".csv"
                onchange={handleFile}
            ></lightning-input>
            <p class="slds-text-color_weak slds-m-top_x-small">
                Use field API names as column headers. Required columns: {requiredText}
            </p>

            <template lwc:if={fileError}>
                <p class="slds-text-color_error slds-m-top_small">{fileError}</p>
            </template>

            <template lwc:if={hasMissingColumns}>
                <p class="slds-text-color_error slds-m-top_small">
                    This file is missing required columns: {missingColumnsText}
                </p>
            </template>

            <template lwc:if={hasRows}>
                <p class="slds-m-vertical_small">
                    {fileName}: {rowCount} rows, {invalidCount} with problems. Showing up to 50.
                </p>
                <div class="preview">
                    <lightning-datatable
                        key-field="_row"
                        data={previewRows}
                        columns={columns}
                        hide-checkbox-column
                    ></lightning-datatable>
                </div>
                <lightning-button
                    class="slds-m-top_small"
                    variant="brand"
                    label={importLabel}
                    disabled={importDisabled}
                    onclick={handleImport}
                ></lightning-button>
            </template>

            <template lwc:if={isImporting}>
                <lightning-spinner alternative-text="Importing"></lightning-spinner>
            </template>

            <template lwc:if={results}>
                <div class="slds-m-top_medium">
                    <p class="slds-text-heading_small">{successCount} imported, {failureCount} failed</p>
                    <ul class="slds-list_dotted slds-m-top_x-small">
                        <template for:each={failures} for:item="failure">
                            <li key={failure.key}>Row {failure.row}: {failure.message}</li>
                        </template>
                    </ul>
                </div>
            </template>
        </div>
    </lightning-card>
</template>
csvImporter.js
import { LightningElement, api } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import importRecords from '@salesforce/apex/CsvImportController.importRecords';

const PREVIEW_LIMIT = 50;
const MAX_ROWS = 2000;

export default class CsvImporter extends LightningElement {
    @api targetObject = 'Contact';
    @api requiredFields = 'LastName';

    fileName;
    fileError;
    headers = [];
    rows = [];
    results;
    isImporting = false;

    get required() {
        return (this.requiredFields || '')
            .split(',')
            .map((f) => f.trim())
            .filter(Boolean);
    }
    get requiredText() {
        return this.required.join(', ') || 'none';
    }
    get missingColumns() {
        const present = this.headers.map((h) => h.toLowerCase());
        return this.required.filter((f) => !present.includes(f.toLowerCase()));
    }
    get hasMissingColumns() {
        return this.headers.length > 0 && this.missingColumns.length > 0;
    }
    get missingColumnsText() {
        return this.missingColumns.join(', ');
    }
    get hasRows() {
        return this.rows.length > 0;
    }
    get rowCount() {
        return this.rows.length;
    }
    get validRows() {
        return this.rows.filter((r) => !r._issue);
    }
    get invalidCount() {
        return this.rows.length - this.validRows.length;
    }
    get previewRows() {
        return this.rows.slice(0, PREVIEW_LIMIT);
    }
    get columns() {
        return [
            { label: 'Row', fieldName: '_row', type: 'number', initialWidth: 70 },
            { label: 'Check', fieldName: '_issue', initialWidth: 200 },
            ...this.headers.map((h) => ({ label: h, fieldName: h }))
        ];
    }
    get importDisabled() {
        return this.isImporting || this.hasMissingColumns || this.validRows.length === 0;
    }
    get importLabel() {
        return `Import ${this.validRows.length} rows`;
    }
    get successCount() {
        return this.results ? this.results.filter((r) => r.success).length : 0;
    }
    get failureCount() {
        return this.failures.length;
    }
    get failures() {
        return (this.results || [])
            .filter((r) => !r.success)
            .map((r) => ({ key: `f-${r.csvRow}`, row: r.csvRow, message: r.message }));
    }

    handleFile(event) {
        const file = event.target.files && event.target.files[0];
        this.reset();
        if (!file) {
            return;
        }
        if (!file.name.toLowerCase().endsWith('.csv')) {
            this.fileError = 'Choose a file that ends in .csv.';
            return;
        }
        this.fileName = file.name;
        const reader = new FileReader();
        reader.onload = () => this.loadCsv(reader.result);
        reader.onerror = () => {
            this.fileError = 'The file could not be read. Try saving it again as CSV (UTF-8).';
        };
        reader.readAsText(file);
    }

    loadCsv(text) {
        const table = parseCsv(text.replace(/^/, ''));
        const body = table.slice(1).filter((cells) => cells.some((c) => c.trim() !== ''));
        if (table.length === 0 || body.length === 0) {
            this.fileError = 'The file needs a header row and at least one data row.';
            return;
        }
        if (body.length > MAX_ROWS) {
            this.fileError = `This file has ${body.length} rows. Split it into files of ${MAX_ROWS} rows or fewer.`;
            return;
        }
        this.headers = table[0].map((h) => h.trim());
        this.rows = body.map((cells, index) => {
            const row = { _row: index + 1 };
            this.headers.forEach((h, i) => {
                row[h] = (cells[i] || '').trim();
            });
            const empty = this.required.filter((f) => {
                const header = this.headers.find((h) => h.toLowerCase() === f.toLowerCase());
                return header && !row[header];
            });
            row._issue = empty.length ? `Missing ${empty.join(', ')}` : '';
            return row;
        });
    }

    async handleImport() {
        const valid = this.validRows;
        const payload = valid.map((r) => {
            const record = {};
            this.headers.forEach((h) => {
                record[h] = r[h];
            });
            return record;
        });
        this.isImporting = true;
        try {
            const results = await importRecords({
                objectApiName: this.targetObject,
                rowsJson: JSON.stringify(payload)
            });
            // Map Apex row numbers back to the row numbers in the file.
            this.results = results.map((r) => ({ ...r, csvRow: valid[r.rowNumber - 1]._row }));
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Import finished',
                    message: `${this.successCount} imported, ${this.failures.length} failed.`,
                    variant: this.failures.length ? 'warning' : 'success'
                })
            );
        } catch (error) {
            this.fileError = (error && error.body && error.body.message) || 'The import failed.';
        } finally {
            this.isImporting = false;
        }
    }

    reset() {
        this.fileName = undefined;
        this.fileError = undefined;
        this.headers = [];
        this.rows = [];
        this.results = undefined;
    }
}

// Parses CSV text into an array of rows. Handles quoted values,
// commas and line breaks inside quotes, and "" as an escaped quote.
function parseCsv(text) {
    const rows = [];
    let row = [];
    let field = '';
    let inQuotes = false;
    for (let i = 0; i < text.length; i++) {
        const c = text[i];
        if (inQuotes) {
            if (c === '"' && text[i + 1] === '"') {
                field += '"';
                i++;
            } else if (c === '"') {
                inQuotes = false;
            } else {
                field += c;
            }
        } else if (c === '"') {
            inQuotes = true;
        } else if (c === ',') {
            row.push(field);
            field = '';
        } else if (c === '\n' || c === '\r') {
            if (c === '\r' && text[i + 1] === '\n') {
                i++;
            }
            row.push(field);
            rows.push(row);
            row = [];
            field = '';
        } else {
            field += c;
        }
    }
    if (field !== '' || row.length) {
        row.push(field);
        rows.push(row);
    }
    return rows;
}
csvImporter.css
.preview {
    height: 320px;
}
csvImporter.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>62.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>CSV Import</masterLabel>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
        <target>lightning__RecordPage</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__AppPage,lightning__HomePage,lightning__RecordPage">
            <property name="targetObject" type="String" label="Object API name" default="Contact"/>
            <property name="requiredFields" type="String" label="Required columns (comma-separated)" default="LastName"/>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Putting it on a page

  1. Deploy the four component files and the two Apex classes with the Salesforce CLI or your usual pipeline.
  2. Give the users who will import files access to CsvImportController through a permission set.
  3. Open Lightning App Builder, drag CSV Import onto a page and set the object and required columns.

The property is called targetObject rather than objectApiName on purpose: on a record page, Salesforce fills objectApiName with the page's own object, which would override your setting.

Ideas for your version

  • Match on an external ID and use Database.upsert to update existing records instead of creating duplicates.
  • Relate rows to the current record by setting a lookup to recordId before saving.
  • Accept spreadsheets by adding the SheetJS library as a static resource.

If you want help adapting this to your org, or you have an import process that has outgrown spreadsheets, get in touch.

Need this adapted to your org?

We build and maintain components like this for clients every month.

Contact us More free resources