CRUD

ApiPlugin auto-generates REST endpoints for every schema. All query parameters are parsed server-side — no manual SQL, no query builders on the backend.


Endpoints

GET /api/:schema

List records.

GET /api/:schema/:id

Get a single record.

POST /api/:schema

Create a record.

PATCH /api/:schema/:id

Update a record.

DELETE /api/:schema/:id

Delete a record.


Pagination

List endpoints are paginated by default.

GET /api/products?page=2&pageSize=10

Response:

{
  "data": [
    {
      "id": 11,
      "name": "Widget"
    },
    {
      "id": 12,
      "name": "Gadget"
    }
  ],
  "meta": {
    "page": 2,
    "pageSize": 10,
    "total": 84,
    "totalPages": 9
  }
}

Filtering

Filters use a nested bracket syntax.

GET /api/products?where[price][$gte]=100&where[isAvailable]=true

Operators

OperatorDescription
$eqEquals (default)
$neNot equals
$gtGreater than
$gteGreater than or equal
$ltLess than
$lteLess than or equal
$inIn array
$ninNot in array
$likePattern match (SQL LIKE)
$nullIs null / is not null

Logical operators

GET /api/products?where[$or][0][price][$lt]=50&where[$or][1][stock][$gt]=100

Sorting

GET /api/products?sort=price          // ascending
GET /api/products?sort=-price         // descending
GET /api/products?sort=category,-price // multiple fields

Field selection

Return only specific fields:

GET /api/products?fields=name,price,stock
GET /api/products?fields=*            // all fields (default)

Populate

Load related records inline.

GET /api/products?populate=*                      // all relations, shallow
GET /api/products?populate[category]=true         // specific relation
GET /api/products?populate[category][fields]=name // with field selection

Nested populate:

GET /api/posts?populate[author][populate][company]=true

Complex queries via QUERY / POST alias

New in 0.2.0. For queries too complex to express in the bracket query-string syntax (deep populate options, long where clauses), send the query as a JSON body instead.

QUERY /api/products
Content-Type: application/json

{
  "where": {
    "price": {
      "$gte": 100
    }
  },
  "populate": {
    "category": true
  },
  "orderBy": [
    "-price"
  ],
  "page": 1,
  "pageSize": 10
}

The body shape is the same ParsedQuery object the bracket syntax produces — no special encoding needed. The response is identical to a GET list response ({ data, meta }).

Because some runtimes and proxies don't yet support the QUERY HTTP method, POST /api/products/query works identically as a portable alias:

POST /api/products/query
Content-Type: application/json

{
  "where": {
    "price": {
      "$gte": 100
    }
  }
}

A request cannot combine a query string (?where[...]=) with a body query — that returns 400.


queryToParams

Building query strings by hand is error-prone. Use queryToParams from @datrix/api to serialize a typed query object into a URL query string.

import { queryToParams } from "@datrix/api";

const qs = queryToParams({
	where: {
		price: { $gte: 100 },
		isAvailable: true,
	},
	populate: { category: true },
	orderBy: ["-price"],
	page: 2,
	pageSize: 10,
} satisfies ParsedQuery<Product>);

const response = await fetch(`/api/products?${qs}`);

queryToParams is fully typed — it accepts the same ParsedQuery<T> shape that the server parses, so your client and server query shapes stay in sync.


Responses

Success — single record

{
  "data": {
    "id": 1,
    "name": "Widget",
    "price": 49.99
  }
}

Success — list

{
  "data": [
    {
      "id": 1,
      "name": "Widget"
    }
  ],
  "meta": {
    "page": 1,
    "pageSize": 25,
    "total": 1,
    "totalPages": 1
  }
}

Error

{
  "error": {
    "type": "DatrixApiError",
    "code": "RECORD_NOT_FOUND",
    "message": "product with id 99 not found",
    "timestamp": "2025-01-01T00:00:00.000Z"
  }
}