GraphQL API
AnvilBase exposes a GraphQL endpoint over every table in your project, served
by pg_graphql running inside the
project database. It speaks the standard GraphQL-over-HTTP protocol, so any
GraphQL client — or a plain fetch — works against it by POSTing to the endpoint.
The GraphQL schema is reflected from your database — tables, columns, relationships, and enums become GraphQL types automatically. No schema to define or keep in sync.
Base path:
/v1/graphql/<project_id>When you configure a client with the project base URL http://<host>/v1/<project_id>
and append graphql/v1 (the conventional pg_graphql path), requests land at
/v1/<project_id>/graphql/v1. Both URL shapes hit the same handler.
Authentication & RLS
GraphQL uses the same authentication and the same Row Level Security context as
the REST API. Send your project key in the apikey header, and
(for a signed-in user) the JWT in Authorization:
apikey: anvilbase_anon_<slug>_…Authorization: Bearer <user-jwt> # optional; makes the request "authenticated"| Scope | Header value | RLS |
|---|---|---|
anon | the anon key | enforced (public rows only) |
authenticated | anon key + user JWT | enforced (the user’s rows) |
service_role | the service_role key | bypassed (admin) |
The resolver runs inside a transaction with SET LOCAL role and
request.jwt.claims set exactly as REST does, so auth.uid(), auth.role(), and
every policy evaluate identically. A GraphQL query can never see rows a REST
query with the same key couldn’t — RLS is enforced in one place, the database.
A missing or invalid credential returns 401 (transport-level), before any GraphQL resolution happens. See API Keys & Scopes.
Request shape
POST a standard GraphQL-over-HTTP body:
{ "query": "{ ... }", // required — the GraphQL document "variables": { }, // optional — variable values (object) "operationName": "MyQuery", // optional — selects one of multiple operations "extensions": { } // optional — protocol extensions}The response is the GraphQL spec envelope, { "data": …, "errors": … }, returned
verbatim from pg_graphql.
GraphQL-over-HTTP convention: a query that is malformed or fails validation returns HTTP 200 with an
errorsarray in the body — not a 500. Only transport/auth/infrastructure failures return a non-200 status. Inspect theerrorsfield, not the HTTP status, for query-level problems.
Naming conventions
pg_graphql reflects your schema with predictable names:
- A table
widgetsbecomes a collection fieldwidgetsCollectionreturning a Relay-style connection (edges { node { … } },pageInfo, etc.). - Columns keep their database names (e.g.
is_public, notisPublic) unless your project enables inflection via a@graphqlcomment directive on the schema. - Foreign keys become nested fields you can traverse in a single query.
See the pg_graphql API reference for
the full set of fields, filters (filter: { col: { eq: … } }), ordering, and
pagination arguments, and for how to customize names with comment directives.
Examples
curl
curl "http://localhost:39001/v1/graphql/<project_id>" \ -H "apikey: $ANON_KEY" \ -H "content-type: application/json" \ -d '{ "query": "{ widgetsCollection { edges { node { id name } } } }" }'With variables
curl "http://localhost:39001/v1/graphql/<project_id>" \ -H "apikey: $ANON_KEY" \ -H "content-type: application/json" \ -d '{ "query": "query ($n: String!) { widgetsCollection(filter: { name: { eq: $n } }) { edges { node { id } } } }", "variables": { "n": "anvil" } }'A GraphQL client or fetch pointed at ${baseUrl}/graphql/v1
pg_graphql has no dedicated SDK method — you POST GraphQL to the
graphql/v1 URL. With fetch:
const baseUrl = 'http://localhost:39001/v1/<project_id>'
const res = await fetch(`${baseUrl}/graphql/v1`, { method: 'POST', headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}`, // or the user's JWT for an authenticated request 'Content-Type': 'application/json', }, body: JSON.stringify({ query: `{ widgetsCollection { edges { node { id name } } } }`, // variables: { … }, }),})
const { data, errors } = await res.json()Any GraphQL client works the same way — point its endpoint at
${baseUrl}/graphql/v1 and set the apikey (and optionally Authorization)
headers. For example, with graphql-request:
import { GraphQLClient } from 'graphql-request'
const client = new GraphQLClient(`${baseUrl}/graphql/v1`, { headers: { apikey: ANON_KEY },})const data = await client.request(`{ widgetsCollection { edges { node { id name } } } }`)Introspection
The standard introspection query works for any valid key:
{ __schema { queryType { name } } }How it works
Under the hood the endpoint executes
graphql.resolve(query, variables, operationName, extensions) against your
project database over the same per-project connection pool the REST engine uses,
inside a transaction carrying the per-request RLS role and claims. pg_graphql
parses the document, plans it against the live schema, and returns the JSON
envelope — there is no separate GraphQL service to run or scale.
The pg_graphql extension is installed in every project database, and the
anon / authenticated / service_role roles are granted access to its resolver
at provisioning time (and back-filled for projects created before this feature).
Those grants are least-privilege: USAGE on the graphql schema plus
EXECUTE on its functions (and read-only SELECT on its internal tables) — the
roles get no write privileges on pg_graphql’s objects, because the resolver
is read-only and reflects your schema from the Postgres system catalogs. Row
visibility is still decided entirely by RLS under the active role.
Read on:
- REST API Overview — the PostgREST-compatible data plane
- Database → Extensions — the full extension set
- pg_graphql documentation — schema reflection, filters, mutations, and directives