Bulk Export
Let users export an app's records to a file — and let the CLI and any REST client do the same. Bulk export is the mirror image of bulk import: where import runs parse → save, export runs source → serialize.
Export is a property of a resource, not a page. You declare which file formats the resource can be exported to; the framework gives you the API, the Export button, and the download.
// packages/homestead-apps/people/resources.ts
bulkExport: {
formats: [
{
id: 'csv',
label: 'CSV',
mimeType: 'text/csv',
extension: 'csv',
load: () => import('./methods/bulk-export-csv'),
},
],
}That declaration alone gets you GET /api/aep/people:bulk-export, an entry in GET /api/custom-methods, homestead resources people bulk-export, and a working Export button.
Table of Contents
- How it works
- Adding bulk export to your app
- Custom sources
- Selecting which records
- The API
- Round-trip with import
- Where code goes
How it works
Fetching and serializing both run server-side, behind a custom method the registry synthesizes from your bulkExport declaration. That's what makes export scriptable — the Export button and a curl are the same call.
Unlike bulk import (a POST that spawns an AEP-151 operation), export is a plain GET that streams the file straight back with a Content-Disposition: attachment header. A download is a read: it needs no request body and no progress polling.
The pipeline has two seams you can override, mirroring import's parser and saver:
- a source fetches the records and shapes them into flat rows (the mirror of import's saver);
- a serializer turns rows into a file's bytes (the mirror of import's parser).
Adding bulk export to your app
1. Write the serializer
For a CSV, declare your columns and let createCsvSerializer do the rest:
// packages/homestead-apps/<feature>/methods/bulk-export-csv.ts
import { createCsvSerializer } from '@rambleraptor/homestead-core/server/bulk-export/csv';
export default createCsvSerializer(['name', 'address', 'notes']);A column may be a bare name (read straight off each row) or an object with a custom accessor and a description:
export default createCsvSerializer<Person>([
{ name: 'name', description: 'Full name' },
{ name: 'city', value: (p) => p.address?.city },
]);createCsvSerializer handles RFC 4180 quoting (values with commas, quotes, or newlines are quoted and embedded quotes doubled), so a file it emits re-parses cleanly.
Serializers must live under your app's methods/ directory.
resources.ts is reachable from the client registry, so the production build stubs out methods/* imports to keep server-only code out of the browser bundle. A serializer outside methods/ ships to every visitor.
A serializer doesn't have to be a CSV — implement BulkExportSerializer directly to emit JSON, an archive, or anything else:
const serializer: BulkExportSerializer<MyRow> = {
serialize(rows, ctx) {
return JSON.stringify(rows, null, 2); // string or Uint8Array
},
};
export default serializer;2. Declare the format
Add bulkExport to the resource in resources.ts (see the top of this page). It's server-only — it never reaches aepbase (the schema-sync wire payload is an explicit whitelist), same as bulkImport and customMethods.
3. Add the button
The button is the same for every app:
import { BulkExportButton } from '@rambleraptor/homestead-core/shared/bulk-export';
import { PEOPLE } from '../resources';
<BulkExportButton plural={PEOPLE} />It reads the resource's formats from the server at runtime, so it needs no changes when you add a file type. With one format it downloads on click; with several it opens a small menu. For a resource that declares no bulkExport, it renders nothing. Pass a filter prop (an aepbase list-filter) to export only the rows currently in view.
Custom sources
By default the export lists the resource's own collection: one row per record, its own columns. When a row means more than that, declare a source — the mirror of import's saver.
bulkExport: {
formats: [...],
source: () => import('./methods/bulk-export-csv'), // the module exports `source`
}export const source: BulkExportSource<PersonRow> = async ({ ctx, filter }) => {
const hs = serverClient(ctx.auth.token);
const people = await hs.collection('people').listAll(filter ? { filter } : undefined);
// ...join across collections, shape each record into a flat row...
return rows;
};A source receives the whole request at once, so it can do the cross-collection joins the default can't. People is the reason: a person's address lives in a sibling address record linked through person-shared-data, and their partner is another person — so exporting "names and addresses" means walking that graph backwards. That's exactly the inverse of what the people importer's saver writes.
Selecting which records
Everything narrows through the one filter channel — including an explicit "export exactly these" selection. The engine's id column is filterable and its in operator takes a list literal, so a picked set of records is just a filter:
id in ["p1", "p2", "p3"]That composes with any other predicate (both apply, AND), and defaults to "everything," so a bare export is unchanged. There's no separate selection API — a record that no longer exists simply isn't returned (the filter matches what's there), the same as any other filter.
A custom source just forwards filter to its list; the engine does the narrowing:
export const source = async ({ ctx, filter }) => {
const people = await hs.collection('people').listAll(filter ? { filter } : undefined);
// ...join and shape rows...
};On the client, build the selection filter with selectionFilter(ids) and pass it to <BulkExportButton>. The reusable useRowSelection hook backs a checkbox list (per-row checkboxes plus a select-all that tracks the current filter):
import { BulkExportButton, selectionFilter } from '@rambleraptor/homestead-core/shared/bulk-export';
const selection = useRowSelection(visibleRows.map((r) => r.id));
// ...checkbox per row wired to selection.isSelected / selection.set...
<BulkExportButton
plural="people"
filter={selectionFilter(selection.selectedIds)} // id in ["…", "…"]
disabled={selection.count === 0}
label="Export selected"
/>The API
GET /api/aep/people:bulk-export?format=csv
→ 200
Content-Type: text/csv
Content-Disposition: attachment; filename="people.csv"
name,address,wifi_network,wifi_password,partner_name
Jane Doe,"123 Main St",HomeWiFi,pw,John DoeQuery params (all optional):
format— a format id declared on the resource. Defaults to the first.filter— an aepbase list-filter passed to the source. Defaults to everything. A record selection rides here asid in ["a", "b", ...].filename— overrides the download filename. Defaults to<plural>.<ext>.
From the CLI or a script:
homestead resources people bulk-export > people.csvRound-trip with import
Because both sides can share a single column list, a CSV you export re-imports cleanly. People does exactly this: its export serializer derives its columns from the same personCsvSchema the importer validates against, so the import template and the export header can never drift.
Where code goes
packages/homestead-apps/<feature>/
├── resources.ts # the `bulkExport` declaration
├── methods/
│ └── bulk-export-csv.ts # serializer (default) + optional `source` — SERVER ONLY
└── components/
└── <App>Home.tsx # renders <BulkExportButton plural={…} />Framework internals, if you need them:
core/resources/bulk-export/types.ts— the contract (formats, sources, serializers, wire shapes)core/server/bulk-export/csv.ts—createCsvSerializerand the CSV escapingcore/server/bulk-export/handler.ts— the shared handlercore/shared/bulk-export/— the button and its hooks