HTTP Adapter
Pair httpAdapter with createAdapterRouteHandler for browser ↔ server adapter calls.
Pair a browser-safe httpAdapter client with a server createAdapterRouteHandler so client components can call a server-only adapter (for example Drizzle wrapping better-sqlite3) without hand-rolling fetch plumbing.
Safe Next.js App Router mount
Pin table-scoped methods and allow-list every column id referenced by facet/filter reads. authorize: () => true alone is not safe on a multi-table schema — getFacetedValues / getFilterOptions / getMinMaxValues resolve tables from columnId, not from a primaryTable pin on fetchData. And validating the facet columnId alone is not enough either: facet requests also carry the active filters in params.filters, so an allow-listed facet target combined with a filter on an off-limits column would leak that column's contents through the counts. Validate every filter leaf — filters can be a flat FilterState[] or a nested FilterGroupNode tree — on every method that carries filters.
Write the guard defensively, too: body validation checks the shape envelope, not that params is present, so a malformed fetchData body reaches authorize. Dereference optionally and return false on shapes you cannot read — a throw inside authorize is caught as a server_error and answered 500, when a malformed request should be a client error.
// app/api/tables/tickets/route.ts
import {
type AdapterRequestBody,
createAdapterRouteHandler,
type FilterGroupNode,
type FilterState,
flattenFilterNode,
isFilterGroupNode,
} from '@better-tables/core';
import { getTicketsAdapter } from '@/lib/tickets';
const ALLOWED_COLUMNS = new Set([
'subject',
'status',
'customer.company',
'createdAt',
// …every column id this endpoint may read
]);
/** Leaf column ids from either filter shape: flat array or nested group tree. */
function collectFilterColumnIds(filters?: FilterState[] | FilterGroupNode): string[] {
if (!filters) return [];
const leaves = isFilterGroupNode(filters) ? flattenFilterNode(filters) : filters;
return leaves.map((filter) => filter.columnId);
}
/** Every column id a request references — facet targets AND their active filters. */
function collectColumnIds(body: AdapterRequestBody): string[] {
switch (body.method) {
case 'fetchData':
// Optional access throughout: the handler validates the shape envelope
// only, so `params` can be absent here. A throw would surface as a 500.
return [
...collectFilterColumnIds(body.params?.filters),
...(body.params?.sorting ?? []).map((sort) => sort.columnId),
...(body.params?.columns ?? []),
];
case 'getFilterOptions':
case 'getFacetedValues':
case 'getMinMaxValues':
// The facet target — plus the active filters the facet is scoped by.
return [body.columnId, ...collectFilterColumnIds(body.params?.filters)];
case 'describeColumns':
return []; // no column ids — pinned to a table in constrainRequest
case 'resolveCellWriteTarget':
return [body.columnId];
case 'cellEdit':
return [body.field]; // 403 anyway without `writes`, but keep the list total
}
}
export const POST = createAdapterRouteHandler(() => getTicketsAdapter(), {
authorize: async (_request, body) => {
// Fail closed on shapes the handler accepts but this guard cannot read:
// `{ method: 'fetchData' }` with no `params` passes body validation.
if (body.method === 'fetchData' && !body.params) return false;
const ids = collectColumnIds(body);
if (!ids.every((id) => ALLOWED_COLUMNS.has(id))) {
return new Response(
JSON.stringify({ ok: false, error: 'Column not allowed on this endpoint.', kind: 'bad_request' }),
{ status: 403, headers: { 'content-type': 'application/json' } }
);
}
return true;
},
constrainRequest: (body) => {
if (body.method === 'fetchData') {
return { ...body, params: { ...body.params, primaryTable: 'tickets' } };
}
if (body.method === 'describeColumns' || body.method === 'resolveCellWriteTarget') {
return { ...body, table: 'tickets' };
}
return body;
},
onError: (error) => console.error('[api/tables/tickets]', error),
});// client
import { httpAdapter } from '@better-tables/core';
const adapter = httpAdapter({ url: '/api/tables/tickets' });Do not expose the full schema unauthenticated
A bare createAdapterRouteHandler(adapter) exposes the entire read surface (fetchData, facets, filter options, min/max, schema/write-target metadata). Always allow-list every referenced column id — facet targets and filter leaves — on every method, pin primaryTable / table where the protocol supports it, and add real authorize for multi-table adapters.
Reference guard used by the marketing demo: apps/marketing/src/lib/demo/support/tickets-adapter-guard.ts.
Wire contract
| Concern | Behavior |
|---|---|
| Maps | getFacetedValues and fetchData.faceted travel as [value, count][]; the client rebuilds Maps |
| Dates | JSON serializes Date values as ISO strings; server emitters parse ISO |
| AbortSignal | Never serialized; client uses it to cancel fetch |
| Mutations | Not proxied by default — cellEdit requires double opt-in (below) |
Writes (opt-in)
Only for genuinely separated frontend/backend deployments. In a monolith, use tables.cellEditAction(def) through your framework's server boundary instead.
Writes are disabled by default and require opting in on both sides:
// server
export const POST = createAdapterRouteHandler(() => getTicketsAdapter(), {
authorize: async (request) => isAllowedToEdit(request),
writes: { columns: ['subject', 'status', 'customer.company'] },
});
// client
const adapter = httpAdapter({ url: '/api/tables/tickets', writes: true });Rules:
- Double opt-in — without handler
writes,cellEdit→ 403; without clientwrites: true, the client stays read-only - Fail closed — server re-resolves column → table/field via schema introspection and coerces values
{ columns }narrowing is recommended —writes: truealone allows any schema-writable columnauthorizeruns before the write
Status semantics
| Situation | HTTP | Envelope |
|---|---|---|
| Success | 200 | { ok: true, result } |
| Malformed / authorize false | 400 / 403 | { ok: false, error, kind: 'bad_request' } |
cellEdit with writes disabled | 403 | { ok: false, error, kind: 'forbidden' } |
| Unknown/unwritable column | 400 | { ok: false, error, kind: 'bad_request' } |
| Adapter / DB throw | 500 | { ok: false, error: 'Adapter request failed.', kind: 'server_error' } |
Server error messages are generic on purpose — use onError for the real exception. Do not echo SQL fragments to clients.
Demo route: apps/marketing/src/app/api/tables/tickets/route.ts.