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/summaryor/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
bookmarkcontinues a walk. Tools that learned on other APIs often writecursor,offsetorpage. 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,Placedreturns only those, which keeps a big table's pages small. And if someone renames one, the read fails withUnknown 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.createdandrecord.updatedstore theaftersnapshot underrecord_id, together withtable.id, since one webhook watches every table, and clear any deleted mark: a record restored from the trash arrives asrecord.created.record.deletedmarks the record deleted rather than removing it. - It keeps each record's
occurred_atand 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=idso 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.