API Data Fetching Tutorial
This tutorial shows the recommended DRY approach to building API routes and UI data fetching using the CRUD factory, custom-field helpers, and UI utilities. We’ll wire a complete entity: API (GET/POST/PUT/DELETE), filtering/sorting/pagination, CSV export, and a React table consuming it.
Table of Contents
- What you’ll build
- Prerequisites
- 1) Scaffold module, entity, and CRUD
- 2) API route with CRUD factory
- 3) Client fetching with UI helpers
- 4) Forms with CrudForm
- 5) Tips
What you’ll build
- Reusable API route powered by the CRUD factory
- Dynamic custom fields (EAV) without hardcoding
- Typed client fetching with UI helpers
- Table with sorting, filtering, and export
Prerequisites
- Node.js 20+, PostgreSQL configured, migrations applied
- Basic understanding of TypeScript, Next.js, and the module system
1) Prepare module, entity, and CRUD
The legacy mercato scaffold commands have been removed while we rebuild them around template files. Until the new generators are ready, create the module skeleton manually (or copy it from packages/example/src/modules/example):
mkdir -p src/modules/inventory/{api,backend,frontend,data,subscribers}
cp packages/example/src/modules/example/index.ts src/modules/inventory/index.ts
# Populate data/entities.ts, data/extensions.ts, ce.ts, and api routes as needed.
The remainder of this tutorial assumes you have an inventory module with a Product entity defined under src/modules/inventory/data/entities.ts.
2) API route with CRUD factory
Generated route structure (simplified):
import { z } from 'zod'
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
import { Product } from '@/modules/inventory/data/entities'
import { E } from '@/generated/entities.ids.generated'
import * as F from '@/generated/entities/product'
import fieldSets from '@/modules/inventory/data/fields'
import { buildCustomFieldSelectorsForEntity, extractCustomFieldsFromItem, buildCustomFieldFiltersFromQuery } from '@open-mercato/shared/lib/crud/custom-fields'
const querySchema = z.object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), sortField: z.string().optional().default('id'), sortDir: z.enum(['asc','desc']).optional().default('asc') }).passthrough()
const createSchema = z.object({}).passthrough()
const updateSchema = z.object({ id: z.string().uuid() }).passthrough()
const cf = buildCustomFieldSelectorsForEntity(E.inventory.product, fieldSets)
const sortFieldMap = { id: F.id, created_at: F.created_at, ...Object.fromEntries(cf.keys.map(k => [`cf_${k}`, `cf:${k}`])) }
export const { metadata, GET, POST, PUT, DELETE } = makeCrudRoute({
metadata: { GET: { requireAuth: true }, POST: { requireAuth: true }, PUT: { requireAuth: true }, DELETE: { requireAuth: true } },
orm: { entity: Product, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
events: { module: 'inventory', entity: 'product', persistent: true },
list: {
schema: querySchema,
entityId: E.inventory.product,
fields: [F.id, F.created_at, ...cf.selectors],
sortFieldMap,
buildFilters: async (q, ctx) => ({ ...(await buildCustomFieldFiltersFromQuery({ entityId: E.inventory.product, query: q as any, em: ctx.container.resolve('em'), orgId: ctx.auth.orgId, tenantId: ctx.auth.tenantId })) }),
transformItem: (item: any) => ({ id: item.id, created_at: item.created_at, ...extractCustomFieldsFromItem(item as any, cf.keys) }),
allowCsv: true,
csv: { headers: ['id','created_at', ...cf.outputKeys], row: (t) => [t.id, t.created_at, ...cf.outputKeys.map(k => String((t as any)[k] ?? ''))], filename: 'products.csv' },
},
create: { schema: createSchema, mapToEntity: (input: any) => ({}), customFields: { enabled: true, entityId: E.inventory.product, pickPrefixed: true } },
update: { schema: updateSchema, applyToEntity: (entity: Product, input: any) => {}, customFields: { enabled: true, entityId: E.inventory.product, pickPrefixed: true } },
del: { idFrom: 'query', softDelete: true },
})
Highlights:
- No hardcoded CFs — selectors, filters, transforms, CSV headers all derive from fieldSets.
- Multi-tenant safe (org/tenant guarded) with soft-delete handling.
- Emits
inventory.product.created|updated|deletedevents.
3) Client fetching with UI helpers
In your React component, use fetchCrudList for typed fetching and buildCrudExportUrl together with the DataTable exporter prop for exports.
import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { DataTable, type DataTableExportFormat } from '@open-mercato/ui/backend/DataTable'
import { fetchCrudList, buildCrudExportUrl } from '@open-mercato/ui/backend/utils/crud'
type ProductRow = { id: string; created_at?: string; cf_sku?: string; cf_price?: number }
export function ProductsTable() {
const params = { page: 1, pageSize: 50, sortField: 'id', sortDir: 'asc' }
const { data, isLoading } = useQuery({
queryKey: ['products', params],
queryFn: () => fetchCrudList<ProductRow>('inventory/products', params),
})
const exportConfig = React.useMemo(() => ({
getUrl: (format: DataTableExportFormat) => buildCrudExportUrl('inventory/products', params, format),
}), [params])
return (
<DataTable
title="Products"
data={data?.items || []}
columns={[{ accessorKey: 'id', header: 'ID' }, { accessorKey: 'cf_sku', header: 'SKU' }, { accessorKey: 'cf_price', header: 'Price' }]}
exporter={exportConfig}
isLoading={isLoading}
/>
)
}
Export what you view
The DataTable-driven export mirrors the slice of data your users see: current filters, search, sort, and column transforms (including any transformItem logic) carry over into the CSV / JSON / XML / Markdown download. Use this when you want the export to be a faithful capture of the on-screen report.
Full data export
If you need a canonical dump—every base column plus every custom field—define list.export in your CRUD factory. By omitting columns, the factory falls back to the raw entity payload (with custom fields resolved) and streams all pages server-side. Alternatively, provide an explicit columns array when you need to control header ordering or include fields that are hidden in the UI. This pattern is ideal for integrations or compliance workflows where consumers expect the entire record, not just the curated view.
4) Forms with CrudForm
Use CrudForm to create/edit entities. Submit to /api/<module>/<route> using createCrud/updateCrud helpers.
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
import { createCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
// ... define Zod schema and fields
<CrudForm
schema={schema}
fields={fields}
onSubmit={(vals) => createCrud('inventory/products', vals)}
successRedirect="/backend/inventory/products"
/>
5) Tips
- Keep module code under
src/modules/<module>. - Define CFs in
ce.ts(entities[].fields); runyarn mercato entities install(optionally with--tenant <id>) to sync them. - Use the helpers from
@open-mercato/shared/lib/crud/custom-fieldsto stay DRY. - If your entity spans related tables, add
customFieldSourcesso the Query Engine joins profile-specific custom fields into the list response. - Emit and process events via the built-in Event Bus; see Events tutorial.
That’s it — you’ve built a complete, DRY CRUD API and UI flow aligned with Open Mercato conventions.