Better Tables
Adapters

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

ConcernBehavior
MapsgetFacetedValues and fetchData.faceted travel as [value, count][]; the client rebuilds Maps
DatesJSON serializes Date values as ISO strings; server emitters parse ISO
AbortSignalNever serialized; client uses it to cancel fetch
MutationsNot 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 client writes: 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: true alone allows any schema-writable column
  • authorize runs before the write

Status semantics

SituationHTTPEnvelope
Success200{ ok: true, result }
Malformed / authorize false400 / 403{ ok: false, error, kind: 'bad_request' }
cellEdit with writes disabled403{ ok: false, error, kind: 'forbidden' }
Unknown/unwritable column400{ ok: false, error, kind: 'bad_request' }
Adapter / DB throw500{ 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.

On this page