Skip to main content

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

  • 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|deleted events.

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); run yarn mercato entities install (optionally with --tenant <id>) to sync them.
  • Use the helpers from @open-mercato/shared/lib/crud/custom-fields to stay DRY.
  • If your entity spans related tables, add customFieldSources so 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.