---
title: Data Tables
description: Structured data for websites — manage via MCP tools, access via Liquid templating, or query via public JSON API.
section: Overview
order: 4
---

# Data Tables

Tables store structured data per website. Each table has a schema with typed fields (`string`, `text`, `number`, `boolean`, `datetime`). Rows are created, updated, and deleted via MCP tools or REST API.

Tables are accessible in three ways:

1. **MCP tools** — `list_tables`, `create_table`, `update_table`, `delete_table`, `list_table_rows`, `create_table_row`, `update_table_row`, `delete_table_row`
2. **Liquid templating** — `site.tables.{slug}` in page HTML
3. **Public JSON API** — `GET /t/{table_id}` on the website's own domain (no auth required)

## Managing Tables

### Create a Table

```
create_table(website, name: "Products", display_column_key: "name", fields_schema: { fields: [
  { name: "name", type: "string" },
  { name: "price", type: "number" },
  { name: "description", type: "text" }
]})
```

- `name` — Human-readable name
- `slug` — URL-safe identifier (auto-generated from name if omitted)
- `display_column_key` — Field used for row slug generation (required)
- `fields_schema` — `{ fields: [{ name, type }] }`
  - Types: `string`, `text`, `number`, `boolean`, `datetime`

### Update a Table

```
update_table(table_id, name: "New Name")
```

Only `name` and `display_column_key` can be updated. Field schema changes require creating a new table.

### List Tables

```
list_tables(website)          # all tables
list_tables(website, table_id: "...")  # single table
```

### Delete a Table

```
delete_table(table_id)
```

Requires confirmation. Permanently removes the table and all rows.

## Managing Rows

### Create a Row

```
create_table_row(table_id, data: { name: "Widget", price: 29.99 })
```

- `data` — Key-value pairs matching the table's field names
- Row `slug` is auto-generated from the `display_column_key` field

### List Rows

```
list_table_rows(table_id)                    # all rows, paginated
list_table_rows(table_id, row_id: "...")     # single row
list_table_rows(table_id, offset: 20, limit: 10)
```

### Update a Row

```
update_table_row(row_id, data: { price: 19.99 })
```

Only provided fields change.

### Delete a Row

```
delete_table_row(row_id)
```

Requires confirmation.

## Liquid Templating

Tables are accessible via `site.tables.{slug}`:

```liquid
{% for row in site.tables.products %}
  <li>{{ row.name }} — ${{ row.price }}</li>
{% endfor %}
```

### Filtering

```liquid
{% for row in site.tables.products | where: "category", "electronics" %}
  {{ row.name }}
{% endfor %}
```

Multiple filters chain together:

```liquid
{% assign filtered = site.tables.products | where: "category", "electronics" | where: "in_stock", "true" %}
```

### Ordering

```liquid
{% for row in site.tables.products | sort_by: "price", "asc" %}
  {{ row.name }}: ${{ row.price }}
{% endfor %}
```

### Pagination

```liquid
{% paginate site.tables.products by 10 order_by: "created_at" order_dir: "desc" %}
  {% for row in site.tables.products %}
    <article>{{ row.name }}</article>
  {% endfor %}
  {% if paginate.next %}<a href="{{ paginate.next.url }}">Next</a>{% endif %}
{% endpaginate %}
```

Pagination parameters:
- `by` — Rows per page (default 20, max 100)
- `order_by` — Field name or `created_at`
- `order_dir` — `asc` or `desc`
- `where` — Filter expression (`"field:value"`)

### Row Properties

Every row exposes:
- `row.id` — UUID
- `row.slug` — Auto-generated slug
- `row.created_at` — Timestamp
- `row.updated_at` — Timestamp
- `row.url` — URL to the row's template page (if configured)
- Any field from the table schema: `row.name`, `row.price`, etc.

### Template Pages

Associate a page with a table to create dynamic row URLs. Path must contain `:slug`; additional `:field_name` placeholders (e.g. `:lang` in `/blog/:lang/:slug`) map to string/text fields in the table schema.

```
create_page(website, path: "/products/:slug", html: "...", table_slug: "products")
create_page(website, path: "/blog/:lang/:slug", html: "...", table_slug: "blog")
```

Each row renders at `/products/{row-slug}`. Template HTML has access to the `row` variable:

```liquid
<article>
  <h1>{{ row.name }}</h1>
  <p>${{ row.price }}</p>
  <div>{{ row.description }}</div>
</article>
```

## Public JSON API

Every table serves a public JSON endpoint at `GET /t/{table_id}` on the website's own domain. **No authentication required.** Always-on for all tables.

### Endpoint

```
GET https://{subdomain}.pageweave.site/t/{table_id}
GET https://{custom-domain}/t/{table_id}
```

- `{table_id}` — Table UUID (not slug). Get it via `list_tables` or the REST API.

### Query Parameters

| Parameter | Description | Default |
|-----------|-------------|---------|
| `page` | Page number | 1 |
| `per` | Rows per page (max 100) | 20 |
| `sort` | Sort field. Prefix with `-` for descending | `created_at` desc |
| `search` | Full-text search across string/text fields | — |
| `filter[field]` | Exact match on field | — |
| `filter[field_neq]` | Not equal | — |
| `filter[field_gt]` | Greater than (numeric fields) | — |
| `filter[field_gte]` | Greater than or equal | — |
| `filter[field_lt]` | Less than (numeric fields) | — |
| `filter[field_lte]` | Less than or equal | — |
| `filter[field_cont]` | Contains (case-insensitive) | — |
| `filter[field_start]` | Starts with (case-insensitive) | — |
| `filter[field_end]` | Ends with (case-insensitive) | — |

### Examples

**Basic listing:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456"
```

**Pagination:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456?page=2&per=10"
```

**Sorting:**

```bash
# Ascending by name
curl "https://my-site.pageweave.site/t/abc123-def456?sort=name"

# Descending by price
curl "https://my-site.pageweave.site/t/abc123-def456?sort=-price"
```

**Filtering:**

```bash
# Exact match
curl "https://my-site.pageweave.site/t/abc123-def456?filter[category]=electronics"

# Numeric range
curl "https://my-site.pageweave.site/t/abc123-def456?filter[price_gte]=10&filter[price_lte]=50"

# Contains (case-insensitive)
curl "https://my-site.pageweave.site/t/abc123-def456?filter[name_cont]=widget"

# Starts with
curl "https://my-site.pageweave.site/t/abc123-def456?filter[name_start]=A"
```

**Search:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456?search=blue widget"
```

**Combined:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456?page=1&per=20&sort=-price&filter[category]=electronics&filter[price_gte]=10"
```

### Response Shape

```json
{
  "rows": [
    {
      "id": "uuid-1",
      "slug": "widget-a",
      "data": { "name": "Widget A", "price": 29.99, "category": "electronics" },
      "created_at": "2026-06-01T12:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "next_page": 2,
    "prev_page": null,
    "total_pages": 5,
    "total_count": 47
  }
}
```

### JavaScript Example

```javascript
const res = await fetch("https://my-site.pageweave.site/t/abc123-def456?filter[category]=electronics&sort=-price");
const { rows, meta } = await res.json();

rows.forEach(row => {
  console.log(`${row.data.name}: $${row.data.price}`);
});
```

### CORS

The API sets `Access-Control-Allow-Origin` only when the request `Origin` matches the website's own origins:
- The pageweave.site subdomain URL
- Any configured custom domain

External origins receive no CORS headers.

### Rate Limiting

60 GET requests per minute per IP for paths starting with `/t/`. Returns `429 Too Many Requests` when exceeded.

### Error Responses

| Status | Meaning |
|--------|---------|
| 404 | Table not found (invalid UUID or table doesn't exist) |
| 410 | Website is suspended |
