Skip to content

Build your own dashboard on top of Tabla

A small app that reads your database through the API: a server part that holds the key, every record, totals, a page per record, and prompts to start.

Updated November 2026

A dashboard on top of Tabla is a small app of your own. It reads your database through the API and shows exactly what you want: the week's totals, a chart by month, a page for each record. Many people now write that app with an AI coding tool. This page is the shape that works, with two prompts at the end that build it that way from the first line.

Two parts: the page, and a small server

The first draft an AI tool tends to write is one HTML file with the API key pasted inside, calling Tabla straight from the browser. It fails with TypeError: Failed to fetch, and the browser's console says why:

Access to fetch at 'https://tabladb.com/api/tables/tbl_…/records?page_size=1000'
from origin 'https://dashboard.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

That refusal is the protection working. Tabla's API answers servers, not web pages on other sites: it sends no cross-origin headers, so a browser won't hand its response to another site's page. The same rule keeps your key out of sight. Anything inside a web page can be read by whoever opens it, and a key reads everything it can reach (where a key must never go).

So a dashboard has two parts:

the page
   │
   ▼
your server   (holds the key)
   │
   ▼
tabladb.com/api
  • The server part keeps the key in an environment variable, calls Tabla, does the adding up, and sends the page only what it draws.
  • The page calls your server's own addresses, such as /api/summary or /records/tbl_…/rec_…, and never Tabla's.

Any host that runs a website with a server behind it will do, and every AI coding tool can write one. The prompt below asks for the split by name. Whoever can open the dashboard sees what its key reads, so put it where only the right people reach it: behind your organization's sign-in, or a password your host puts in front of the site.

If what you want is to show people a table as it is, with nothing to build, a public view link does that with no key and no server.

A read-only key for the one database

Give the dashboard a key of its own. Open the account menu (your avatar, top right), choose API keys…, and name the key after the dashboard. Under the name, set Read and write to Read only, and All databases to Choose databases with just this one checked. Then Create, and copy the key into your server's environment.

A read-only key reads what a viewer reads and is refused every write with This API key is read-only., so a mistake in the dashboard's code can't change your data. Narrowed to one database, it can't see any other. Both settings can be changed later without changing the key (API keys).

The key can find its own way in. GET /api/databases answers a narrowed key with just its one database, db_…, and GET /api/databases/db_…/tables lists that database's tables with their fields and ids. (On screen, the database's id is in the address bar on its Webhooks tab, and a table's id is in the address bar whenever the table is open.)

Reading every record

Records come in pages of up to 1,000. A page with more after it carries a bookmark; send it back to get the next page, and stop when a response has none. On your server:

// TABLA_API_KEY is set in the server's environment, never in the page.
const KEY = process.env.TABLA_API_KEY;

async function allRecords(tableId, params = {}) {
  const records = [];
  let bookmark;
  do {
    const url = new URL(`https://tabladb.com/api/tables/${tableId}/records`);
    url.searchParams.set("page_size", "1000");
    for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
    if (bookmark) url.searchParams.set("bookmark", bookmark);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    const page = await res.json();
    records.push(...page.records);
    bookmark = page.bookmark; // absent on the last page
  } while (bookmark);
  return records;
}

Three habits keep it fast and correct:

  • Only bookmark continues a walk. Tools that learned on other APIs often write cursor, offset or page. None of those is a parameter here, so each is ignored and the first page comes back again: a loop that never ends, or the same records counted twice. Why it works this way is in paging.
  • Name the fields you use. fields=Status,Total,Placed returns only those, which keeps a big table's pages small. And if someone renames one, the read fails with Unknown field "Total". instead of quietly adding up to zero.
  • Read once, then reuse. Each key has 600 requests a minute, and a 100,000-record table is 100 pages. Keep what you read on the server for a minute or so rather than reading again for every person who opens the dashboard.

view= reads a table through one of its saved views, with that view's filter and sort, so whoever keeps the database can change what the dashboard counts from the app. filter= narrows the records directly (filtering).

Totals are added up by the dashboard

The API returns records, and the sums, counts and averages are the dashboard's to work out. It is a few lines on the server, once per read:

const orders = await allRecords("tbl_…", { fields: "Status,Total" });
const totalByStatus = {};
for (const r of orders) {
  const status = r.fields.Status ?? "No status";
  totalByStatus[status] = (totalByStatus[status] ?? 0) + (r.fields.Total ?? 0);
}

Values arrive ready to add. Numbers, currencies and percents are plain numbers (a percent of 10% is 10), a select is its choice's name, a date is ISO 8601 text, a linked record is a list of { "id", "display" } pairs, formulas and rollups are their computed values, and an empty cell is null, except a link with nothing linked, which is [].

A page per record

GET /api/tables/tbl_…/records/rec_… returns one record with every field, inside a record key:

{"record": {"id": "rec_f54n9786kdi9",
  "created_at": "2026-11-04T16:47:59.547Z",
  "updated_at": "2026-11-04T16:47:59.547Z",
  "fields": {"Name": "ORD-00001", "Status": "New", "Total": 10,
    "Placed": "2026-01-01", "Discount": 10,
    "Customer": [{"id": "rec_phdvqsvrkzna", "display": "Amal Haddad"}]}},
 "field_ids": {"Name": "fld_je363pspygjf", "…": "…"}}

Reading a record takes its table's id as well as its own, so give record pages both: /records/tbl_…/rec_…. A link carries the linked record's id, and the link field's options.target_table_id, in the tables list, names the table it points into, so an order's page can link straight to its customer's. Ids never change, so a page's address works for as long as the record exists.

Keeping a live copy in your own database

When a dashboard is opened by many people at once, reads tables past a few thousand records, or draws charts you'd rather write in SQL, keep a copy of the records in a database of your own and let the dashboard read that. Webhooks keep it current, and a nightly read keeps it whole.

First, in Tabla. On the database's Webhooks tab, press Add webhook three times: one each for a record is created, a record is updated and a record is deleted, each in any table, each with Send to set to your server, for example https://dashboard.example.com/tabla-hook. Under Example payload, set Cells keyed by to Field id, so renaming a field changes nothing in your copy. Each webhook has its own Signing secret, and your server needs all three. A webhook's address has to be on the public internet, so this half starts working once the dashboard is deployed.

Then your server does four things, and the second prompt below asks for each:

  • It checks every delivery's signature against the three secrets before trusting any of it, and ignores the sample that Send a test posts, which carries "test": true.
  • It applies the delivery. record.created and record.updated store the after snapshot under record_id, together with table.id, since one webhook watches every table, and clear any deleted mark: a record restored from the trash arrives as record.created. record.deleted marks the record deleted rather than removing it.
  • It keeps each record's occurred_at and skips a delivery older than the one it already applied. Deliveries can arrive late or twice, and the deleted mark is what stops a late update from bringing a deleted record back.
  • It reads every table again once a night, with key_by=id so the cells match, and writes what it reads over the copy, leaving alone any record a delivery has touched since the read began. Records the read didn't find are marked deleted. Everything the read writes is stamped with the moment it began, as if a delivery had arrived then, so a late delivery from before the read can't undo it. The same read fills an empty copy the first time.

The nightly read is what keeps the copy whole. Webhooks carry each edit to the record that was edited, as it happens. A change to 50 records or more at once, such as a paste, an import, or one request that edits, deletes or restores that many, sends no webhooks by design, and the nightly read brings it in. The far side of a link moves without a delivery too: add an order, and its customer's list of orders and any rollup across them change while the customer itself wasn't edited. So add up anything that crosses a link, such as a customer's lifetime total, from the copy's own orders, and show a linked record's name from its own record in the copy rather than from the link's display. Then both move with every delivery.

GET /api/databases/db_…/export is the whole database in one request: the same zip as Export database…, with one CSV per table and each record's id on its line. It makes a good nightly backup to keep beside the copy (inside the export).

Prompts to start from

Paste this into your AI coding tool and fill in the bracketed parts. It asks for the two-part shape from the start, so nothing has to be taken apart later.

Build a small dashboard web app for my Tabla database.

Shape:
- A server part and a browser page. Only the server talks to Tabla; the page
  only calls the server's own routes. Tabla's API does not answer web pages
  on other sites, so never call it from browser code.
- The server reads the API key from the environment variable TABLA_API_KEY.
  The key never appears in browser code, the HTML, or any response.

Tabla's API (full description: https://tabladb.com/openapi.json):
- Every address starts with https://tabladb.com/api; send the header
  Authorization: Bearer <key>.
- GET /databases returns { databases: [...] }; my key reaches only one.
- GET /databases/<database id>/tables returns { tables: [...] }, each table
  with its id, name and fields. A link field's options.target_table_id is
  the table it points into.
- GET /tables/<table id>/records?page_size=1000 reads records. If the
  response has a "bookmark", request again with bookmark=<that value>; when
  it has none, that was the last page. There is no cursor, offset or page
  parameter.
- Add fields=<comma-separated field names> to read only the fields you use.
- Each record is { id, created_at, updated_at, fields: { <name>: value } }.
  Linked records arrive as [{ id, display }]. Empty cells are null, except
  an empty link, which is [].
- GET /tables/<table id>/records/<record id> returns { record: {...} }.
- Compute every total, count and average on the server from the records
  you read.
- Keep what you read in memory for 60 seconds, so opening the dashboard
  doesn't read every record again.

What to show:
- An overview: [for example: the total of Amount by Status, and a bar chart
  of Amount by month of Date].
- A page per record at /records/<table id>/<record id> with every field,
  linking to the pages of the records it links to.

The tables I care about are: [for example: Orders (Name, Status, Total,
Placed, Customer) and Customers (Name, Region)].

When you want the live copy, add the three webhooks first, then send this as a second request:

Add a live copy of my Tabla data to the dashboard, kept in [SQLite, or my
own database], and have the dashboard read from the copy instead of Tabla.

- Three Tabla webhooks (created, updated, deleted, any table) POST to
  /tabla-hook. Their signing secrets are in the environment variables
  TABLA_SECRET_CREATED, TABLA_SECRET_UPDATED and TABLA_SECRET_DELETED.
- Verify each delivery before parsing it. X-Tabla-Signature is "sha256="
  followed by the hex HMAC-SHA256 of X-Tabla-Timestamp + "." + the raw
  body, keyed with the secret. Accept it if it matches one of the three
  secrets, compared in constant time, and the timestamp is under 5 minutes
  old; otherwise answer 401.
- Ignore deliveries with "test": true.
- A delivery is { event, record_id, occurred_at, table: { id }, after:
  { fields } }, cells keyed by field id. For record.created and
  record.updated, store after.fields, table.id and occurred_at under
  record_id, and clear any deleted mark (restoring a record from the trash
  sends record.created). For record.deleted, mark the record deleted and
  store occurred_at; don't remove it. Skip any delivery whose occurred_at
  is older than the one stored for that record.
- Once at start and once a night, read every table with key_by=id, paging
  with bookmark as before. Take the read's start time from the Date header
  of a request sent before the first page (GET /databases will do). Write
  each record over the copy, clearing any deleted mark, unless a delivery
  newer than that start time touched it; then mark deleted every record of
  that table the read didn't return and no newer delivery touched. Stamp
  every record the read writes or marks deleted with that start time, as if
  a delivery had arrived then.
- Add up anything across a link, like a customer's total of their orders,
  from the copy's own records rather than from rollup fields, and show a
  linked record's name from its own record in the copy, not from the
  link's display.

Tabla is the database we build these on.

A no-code database with real Postgres underneath: every feature on every plan, a million records per database, and your whole database back out in one file, any day.

More guides