A front end on the GraphQL API, step by step
This guide takes an Exponential 6.0.15 site from "GraphQL switched off" to a running front end that reads its content through the GraphQL API: the API switched on for one siteaccess, a key for the front end, a starter project generated from the site's own schema, its queries stored on the server and answered from the response cache, and, if the front end also writes, a key that may write (the mutations come on with the API). Every step says what to run, what you should see and how to check it.
It is written for a developer with a shell on the server (for ./console) and an administrator account. The
reference for everything used here is The GraphQL API; copy-paste solutions for common
front ends are in GraphQL recipes.
| Time | 20 minutes, plus npm install |
| You need | Exponential 6.0.15, webonyx/graphql-php 15 (optional; installed by the kernel's own Composer set-up, see The library), a shell in the installation root, Node.js 20 or later for the JavaScript kits |
| You get | an end point at https://<your site>/api/graphql, a personal API key, a Next.js, Nuxt, Astro, plain HTML or Expo project reading the site |
Contents
- Check that the API can run
- See what switching it on changes
- Switch it on
- Look at Setup > GraphQL
- Ask the first question
- Generate the starter project
- Run it
- Store its queries on the server
- Writing: a key with graphql:write
- Before going live
- When something does not work
1. Check that the API can run
./console exp:graphql:status --siteaccess=siteThe last line says PASS the GraphQL API can run (switched off here): the library is installed and the schema builds
from your content classes. schema names its hash and the number of classes and fields; string only lists the
datatypes that have no type of their own (they answer their text). FAIL GraphQL API unavailable: the library is missing means composer install --no-dev left it out (the library is optional), or [GraphQLSettings] LibraryPath points nowhere; the library is not webonyx/graphql-php 15 means a platform installed another version. See The library.

2. See what switching it on changes
Name the siteaccess the front end reads, the address the front end will run at during development, and the user the
front end reads as. Without --apply nothing is written:
./console exp:graphql:enable --siteaccess=site --origin=http://localhost:3000 --key-for=frontendThe command first builds the schema (PASS the schema builds: 88 classes, schema 00818b7ff593), then prints, for every
settings file, the diff it would write:
DRY RUN would change settings/override/graphql.ini.append.php
+[GraphQLSettings]
+Enabled=enabled
+SiteAccessList[]=site
DRY RUN would change settings/override/rest.ini.append.php
+[CorsSettings]
+Cors=enabled
+AllowedOrigins[]=http://localhost:3000
DRY RUN would make an API key for frontend with graphql:read- The user of the key decides what the front end can read: make a user (here
frontend) in a group whose role allowscontent/readon what the site shows, and nothing more. The anonymous user is enough for a public site; then leave--key-forout. --originis only needed when a browser calls the API directly (the plain HTML kit, a client-side app). The Next.js, Nuxt and Astro kits call it from the server.

3. Switch it on
The same command with --apply:
./console exp:graphql:enable --siteaccess=site --origin=http://localhost:3000 --key-for=frontend --applyPASS wrote settings/override/graphql.ini.append.php
PASS wrote settings/override/rest.ini.append.php
PASS API key expk_qkjdsz3uznp8 for frontend (graphql:read), shown once:
expk_qkjdsz3uznp8_95UvceuCwfzIUjYhD8SidKTprQ756aTKC7XyjRcFCopy the key now: it is shown this once and stored only as a hash. Each file was backed up to var/backup/ini/
first, the change is in the audit (system.setting.write, access.apikey.create), and the INI cache was cleared, so
PHP-FPM and Velocity read the new settings on their next request. Check:
./console exp:graphql:status --siteaccess=site # enabled yes
4. Look at Setup > GraphQL
In the administration, Setup > GraphQL now shows the end point, the library, the schema, and a table of every
siteaccess with the API on in site and off elsewhere. Below it: the limits of the siteaccess, the response cache
(empty so far), the persisted queries (none yet), the API keys with a GraphQL scope (the key of step 3, active, never
used), and the playground. With the policy graphql/administrate the table has the buttons Switch on, Switch
off, Mutations on and Mutations off: they write the same settings as the commands.


The playground is shown when the API is also switched on in the siteaccess of the administration; while it is off there, a notice at the top says so and the table shows where the API answers.
Type a query into the playground and press Run (Ctrl+Enter):
{ node(urlAlias: "/") { name children(limit: 5) { totalCount nodes { name urlAlias classIdentifier } } } }It runs with your rights, as the signed-in administrator, so it may show more than the front end will.
5. Ask the first question
From a shell, as the front end will, with the key:
curl -s https://www.example.com/api/graphql \
-H "Authorization: Bearer $EXP_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"query": "{ currentUser { login signedInBy } node(urlAlias: \"fit-healthy\") { name children(limit: 3) { nodes { name urlAlias } } } }"}'{"data":{"currentUser":{"login":"frontend","signedInBy":"apikey"},"node":{"name":"Fit & Healthy","children":{"nodes":[{"name":"Fitness","urlAlias":"fit-healthy/fitness"},{"name":"Healthy eating","urlAlias":"fit-healthy/healthy-eating"},{"name":"Recipes","urlAlias":"fit-healthy/recipes"}]}}}}signedInBy is apikey: the request ran as the key's owner. Without the header it runs as the anonymous user
(anonymous), and the schema (introspection) is closed to it.

6. Generate the starter project
./console exp:graphql:starter --list
./console exp:graphql:starter --kit=next --siteaccess=site --to=../shop-front # what it would write
./console exp:graphql:starter --kit=next --siteaccess=site --to=../shop-front --apply
| Kit | For | Runs the queries |
|---|---|---|
html | a page without a build step | in the browser (needs --origin of step 2) |
next | Next.js 15, App Router | on the server (React server components) |
nuxt | Nuxt 3 | on the server (the app's own /api/graphql route adds the key) |
astro | a static site | at build time |
expo | an iOS, Android and web app (React Native) | in the app |
Before anything is written, every query the kit gets is checked against the schema and its limits. The project has:
- the kit's files (shipped in
share/graphql/starters/<kit>/), with the end point filled in (--endpoint=to name another address thansite.ini [SiteSettings] SiteURL); - the generated queries:
Menu(the children of the site's root),Node(a page by its URL alias: name, class, parent, children) and one query per content class (Content_ng_article...) that reads every field of the class: images in the aliaslargewith their alternative text, rich text as HTML, relations by name and URL alias. They are in the kit's module (lib/queries.tsfor Next.js) and ingraphql/*.graphql; - a README with the next steps.
The queries follow the schema of the moment. After a content class changed (exp:graphql:status shows a new schema
hash), generate the kit again into a new directory and copy lib/queries.ts and graphql/ over.
7. Run it
The --apply of step 6 writes the project and ends with the commands that start it:

cd ../shop-front
echo "EXP_GRAPHQL_KEY=$EXP_API_KEY" >> .env.local
npm install
npm run dev # http://localhost:3000/The home page shows the menu of the site's root; every address of the site is a page (/fit-healthy/fitness is the
category, /fit-healthy/fitness/why-track-your-workouts the article) with the fields of its class and its children.
Change components/Field.tsx and app/[[...path]]/page.tsx to design your pages: the data is already there.
8. Store its queries on the server
A stored (persisted) query is called by its name instead of its text, cannot be changed by whoever calls it, and is the only kind the response cache keeps:
cd - # back to the installation
./console exp:graphql:persist ../shop-front/graphql/ --siteaccess=site # check: 90 queries fit the schema
./console exp:graphql:persist ../shop-front/graphql/ --siteaccess=site --apply # store them
curl -s 'https://www.example.com/api/graphql?id=menu' # a GET by name
curl -sI 'https://www.example.com/api/graphql?id=menu' | grep -i x-graphql-cache # MISS, then HITThe second call is answered from the cache, at the start of index_rest.php, in about a millisecond; publishing
content expires the cache. With PersistedQueriesOnly=anonymous the site then refuses every ad-hoc query of
anonymous requests: a good setting for production once the front end only sends stored queries. Setup > GraphQL lists
the stored queries and the size of the cache.


9. Writing: a key with graphql:write
The mutations came on with the API in step 3 (MutationsEnabled=enabled is the default), but nothing can write yet:
a mutation needs a signed-in user, and a key needs the scope graphql:write. When the front end changes content (a
contact form that creates content, an editor app), give the writing user such a key (--mutations also switches the
mutations on again, should they have been switched off for the siteaccess):
./console exp:graphql:enable --siteaccess=site --mutations --key-for=editor --apply
The key of editor gets graphql:read graphql:write, and still only does what the roles of editor allow.
curl -s https://www.example.com/api/graphql -H "Authorization: Bearer $EDITOR_KEY" -H 'Content-Type: application/json' \
-d '{"query": "mutation { createContent(parentId: 2, classIdentifier: \"folder\", fields: [{identifier: \"name\", value: \"From the front end\"}], publish: false) { status contentId version } }"}'{"data":{"createContent":{"status":"DRAFT","contentId":59338,"version":1}}}Every mutation is recorded in the audit (content.graphql.mutation), runs the workflows of the kernel's operations,
and expires the response cache. Files go as multipart requests (uploadImage), and a draft can be shown before it is
published with a preview token. See Mutations.
Never put a key with graphql:write into a browser or an app that others install: keep it on a server.
10. Before going live
- To do:
--originlists the production address of a browser front end, and nothttp://localhost:3000any more (settings/override/rest.ini.append.php,AllowedOrigins[]). - To do: The key's user reads only what the site shows; a browser or app gets no key, or one with
graphql:readonly. - To do: The front end's queries are stored (
exp:graphql:persist), andPersistedQueriesOnly=anonymous(orenabled). - To do: The rate limits fit the expected traffic (
RateLimit,RateLimitAnonymous; Setup > GraphQL shows them). - To do: A siteaccess where nothing writes has
MutationsEnabled=disabled(exp:graphql:disable --siteaccess=<name> --mutations --apply), andexp:graphql:keyslists no key you do not know. - To do:
Debug=disabledon the production siteaccess.

11. When something does not work
| You see | Because | Do |
|---|---|---|
404 and DISABLED | the API is off in the siteaccess the request reached | exp:graphql:status --siteaccess=<it>; a request reaches a siteaccess by host, path or the header X-Siteaccess |
403 insufficient_scope | the key has no graphql:read | make a key with exp:graphql:keys --create --for=<login> --apply |
null for content you know is there | the user may not read it | check the role of the key's user, or try the query in the playground as that user's role |
| a browser error about CORS | the front end's origin is not allowed | exp:graphql:enable --origin=<the address> --apply |
429 and RATE_LIMITED | over the rate limit | wait for Retry-After, or raise the limit for the siteaccess |
FORBIDDEN on a mutation | signed out, no graphql:write, or the user's policies | the error message names which |
The error codes are listed under Errors.

Related pages
- The GraphQL API: the reference
- GraphQL recipes: a blog, a mobile app, a static build, an AI agent
- Personal API keys
- Commands: exp:graphql:enable, exp:graphql:starter, exp:graphql:persist, exp:graphql:keys