Import / Export
Generic CSV and JSON import/export toolkit for any Prisma-backed table.
Generate with AI
Use the prompt below in your AI assistant to add import/export to any existing admin table.
The full spec is in .ai-specs/feature-import-export.md.
Open the target table component, then run in the chat panel:
Add import and export to this table using the import/export feature.
@.ai-specs/feature-import-export.md
Follow the checklist at the bottom of the spec.
The Prisma model unique key for deduplication is: [slug / id / name — pick one].
Required CSV columns are: [list your columns].Open the target table component, then use inline chat (Ctrl+I / ⌘I):
Add import and export to this table using the import/export feature.
#file:.ai-specs/feature-import-export.md
Follow the checklist at the bottom of the spec.
The Prisma model unique key for deduplication is: [slug / id / name — pick one].
Required CSV columns are: [list your columns].Attach .ai-specs/feature-import-export.md and the target table component file to the conversation, then send:
Add import and export to the attached table component.
Use the import/export spec (also attached) as the implementation guide.
Follow the checklist at the bottom of the spec.
The Prisma model unique key for deduplication is: [slug / id / name — pick one].
Required CSV columns are: [list your columns].Overview
The @/features/common/import-export module provides a reusable, hook-driven import/export system that works with any Prisma model. It handles file parsing, validation, preview, deduplication, and client-side download with minimal wiring effort.
Stack: react-dropzone, papaparse, sonner (toasts), shadcn/ui.
Folder structure
src/features/common/import-export/
├── model/import-export.schema.ts # ImportRow type, ImportResult schema
├── service/import-export.service.ts # importRows() server-side helper
├── utils/parse.ts # parseCsv(), parseJson()
├── utils/export.ts # exportToCSV(), exportToJSON()
├── hooks/use-import.ts # useImport() hook
├── hooks/use-export.ts # useExport() hook
├── components/import-modal.tsx # Dialog UI driven by useImport
├── components/preview-table.tsx # Row preview table before commit
└── index.ts # Barrel — import everything from hereuseImport
Manages the import flow state machine: idle → parsing → preview → importing → done.
const imp = useImport({
requiredHeaders: ['title', 'slug', 'shortDescription', 'content', 'published', 'authorName'],
onImport: myServerAction, // (rows: ImportRow[]) => Promise<ImportResult>
});
// Trigger
<Button onClick={() => imp.setOpen(true)}>Import</Button>
// Dialog
<ImportModal {...imp} onOpenChange={imp.setOpen} />Options
| Prop | Type | Description |
||||
| requiredHeaders | string[] | CSV columns that must be present. Validated on file parse. |
| onImport | (rows: ImportRow[]) => Promise<ImportResult> | Your 'use server' action |
Return values
| Field | Type | Description |
||||
| open | boolean | Dialog open state |
| setOpen | (v: boolean) => void | Open/close; closing resets everything |
| phase | ImportPhase | 'idle' \| 'parsing' \| 'preview' \| 'importing' \| 'done' |
| rows | ImportRow[] | Parsed rows (available from preview phase onwards) |
| result | ImportResult \| null | { imported: number, skipped: number } after completion |
| onFileAccepted | (file: File) => Promise<void> | Called by dropzone on file drop |
| onConfirmImport | () => Promise<void> | Called when user clicks "Import N rows" |
| reset | () => void | Reset to idle, clear rows and result |
useExport
Thin wrapper over exportToCSV / exportToJSON for when data is already in memory.
const exp = useExport({ data: rows, filename: 'blog-posts' });
<Button onClick={exp.exportAsCSV}>Export CSV</Button>
<Button onClick={exp.exportAsJSON}>Export JSON</Button>For async export (fetch all records)
When export data must be fetched first (e.g. all pages):
const exportQuery = useQuery({ ...orpc.blog.export.queryOptions({}), enabled: false });
const [isExporting, setIsExporting] = useState(false);
const handleExport = async (format: 'csv' | 'json') => {
setIsExporting(true);
try {
const { data } = await exportQuery.refetch();
if (!data) return;
const rows: ImportRow[] = data.map((post) => ({
title: post.title,
slug: post.slug,
// ... map all exportable fields to plain string/primitive values
}));
if (format === 'csv') exportToCSV(rows, 'blog-posts');
else exportToJSON(rows, 'blog-posts');
} finally {
setIsExporting(false);
}
};importRows (server-side)
Use inside a 'use server' action. Handles deduplication: calls findDuplicate for each row and skips it if it returns true.
'use server';
import { importRows, type ImportRow, type ImportResult } from '@/features/common/import-export';
export async function importMyRecords(rows: ImportRow[]): Promise<ImportResult> {
return importRows({
rows,
findDuplicate: async (row) => {
const existing = await prisma.myModel.findUnique({
where: { slug: String(row.slug) },
});
return !!existing;
},
create: async (row) => {
await prisma.myModel.create({
data: {
title: String(row.title ?? '').trim(),
slug: String(row.slug ?? '').trim(),
},
});
},
});
}ImportModal
Stateless dialog component driven entirely by useImport return values.
<ImportModal
open={imp.open}
onOpenChange={imp.setOpen}
phase={imp.phase}
rows={imp.rows}
result={imp.result}
onFileAccepted={imp.onFileAccepted}
onConfirmImport={imp.onConfirmImport}
reset={imp.reset}
/>
// Or with spread (equivalent):
<ImportModal {...imp} onOpenChange={imp.setOpen} />UX flow
- idle — dropzone visible. User drags or clicks to select a
.csvor.jsonfile. - parsing — file is being parsed. Dropzone shows "Parsing file…".
- preview —
PreviewTableshows up to 50 rows. User reviews and clicks "Import N rows". - importing — button shows "Importing…".
- done —
Alertshows imported / skipped counts.toast.success()also fires.
DataTable integration
Pass import/export controls via the toolbarActions prop — they render right-aligned after the filter inputs:
<DataTable
...
toolbarActions={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">Export</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => void handleExport('csv')}>Export as CSV</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleExport('json')}>Export as JSON</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={() => imp.setOpen(true)}>Import</Button>
</>
}
/>
<ImportModal {...imp} onOpenChange={imp.setOpen} />Adding to a new table (checklist)
- Server action →
src/features/{workspace}/{domain}/actions/import-{domain}.ts - Export oRPC procedure → add
{domain}.exportto the feature router (returns all records, no pagination) - Hook calls →
useImport+ handle export in the table component toolbarActionsprop → pass the buttons to<DataTable><ImportModal />→ render next to the table