What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
APIs
Query Agility's GraphQL API from Next.js with generated TypeScript types, a committed schema snapshot, Next.js caching and webhook revalidation, and a CI check that catches drift.
This guide shows how to query Agility's GraphQL API from a Next.js App Router site with full TypeScript types. You write the query, its result type is generated for you, and when a query and your content models disagree you get a build failure instead of a blank page.
The approach:
fetch in server components sends the queries. No GraphQL client library runs in the browser or on the server."use cache" + cache tags) is cleared by Agility's publish webhook.Hand-written interfaces drift. A model field gets renamed, the interface doesn't, and the page quietly renders nothing. With generated types:
graphql-codegen.tsc.npm i -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-typed-document-node/core graphql
All four are development dependencies.
Agility generates the schema from your content models. Save it to a file and commit it, so type generation never depends on the network:
// node/graphql-schema.ts (run with: npx tsx node/graphql-schema.ts)
import { writeFile } from "fs/promises"
import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql"
const res = await fetch(`https://api.aglty.io/v1/${process.env.AGILITY_GUID}/fetch/en-us/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: process.env.AGILITY_API_FETCH_KEY! },
body: JSON.stringify({ query: getIntrospectionQuery() }),
})
const { data } = await res.json()
await writeFile("src/lib/graphql/schema.graphql", printSchema(buildClientSchema(data)))
Re-run it whenever you change a content model.
// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli"
const config: CodegenConfig = {
schema: "src/lib/graphql/schema.graphql",
documents: ["src/**/*.{ts,tsx}", "!src/lib/graphql/__generated__/**"],
ignoreNoDocuments: true,
generates: {
"src/lib/graphql/__generated__/": {
preset: "client",
presetConfig: { fragmentMasking: false },
config: {
documentMode: "string", // queries become typed strings for fetch
useTypeImports: true,
enumsAsTypes: true,
strictScalars: true, // fail if Agility adds an unmapped scalar
scalars: { DateTime: "string", Decimal: "number", JSON: "unknown" },
},
},
},
}
export default config
documentMode: "string" is the key setting. Each query is generated as a TypedDocumentString: a plain string you can POST with fetch that carries its result and variable types. strictScalars makes generation fail if a custom scalar isn't mapped; Agility's schema uses DateTime, Decimal and JSON.
Add scripts to package.json:
"graphql:schema": "tsx node/graphql-schema.ts",
"graphql:codegen": "graphql-codegen"
// src/lib/graphql/agilityGraphQL.ts
import "server-only"
import { cacheLife, cacheTag } from "next/cache"
import { connection } from "next/server"
import type { TypedDocumentString } from "./__generated__/graphql"
export async function agilityGraphQL<TResult, TVariables>(
document: TypedDocumentString<TResult, TVariables>,
{ locale, preview, tags, variables }: { locale: string; preview: boolean; tags: string[]; variables?: TVariables }
): Promise<TResult> {
const query = document.toString()
if (preview) {
await connection() // preview is per-request, never cached
return (await execute(query, variables, locale, true)) as TResult
}
return (await cached(query, variables, locale, tags)) as TResult
}
async function cached(query: string, variables: unknown, locale: string, tags: string[]) {
"use cache"
cacheTag(...tags)
cacheLife("days")
return execute(query, variables, locale, false)
}
async function execute(query: string, variables: unknown, locale: string, preview: boolean) {
const res = await fetch(
`https://api.aglty.io/v1/${process.env.AGILITY_GUID}/${preview ? "preview" : "fetch"}/${locale}/graphql`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: (preview ? process.env.AGILITY_API_PREVIEW_KEY : process.env.AGILITY_API_FETCH_KEY) ?? "",
},
body: JSON.stringify({ query, variables }),
}
)
const json = await res.json()
if (!res.ok || json.errors?.length) throw new Error(json.errors?.map((e: { message: string }) => e.message).join("; "))
return json.data
}
The query string, not the typed document object, crosses the "use cache" boundary, because arguments to a cached function must be serializable.
// src/lib/cms-content/getOffices.ts
import "server-only"
import { graphql } from "@/lib/graphql/__generated__"
import type { OfficesQuery } from "@/lib/graphql/__generated__/graphql"
import { agilityGraphQL } from "@/lib/graphql/agilityGraphQL"
const OfficesDocument = graphql(`
query Offices {
offices(take: 250, sort: "fields.city") {
contentID
fields { title city zip phone mapLink { href } }
}
}
`)
export type Office = NonNullable<NonNullable<OfficesQuery["offices"]>[number]>
export const getOffices = async (locale: string, preview: boolean) => {
const data = await agilityGraphQL(OfficesDocument, {
locale,
preview,
tags: [`agility-content-offices-${locale}`],
})
return (data.offices ?? []).filter((o): o is Office => !!o)
}
Run npm run graphql:codegen. OfficesQuery is generated from the query itself, and you can call getOffices from any server component. Asking for a field that isn't in the model, or reading one you didn't query, is now a compile error.
Tag each query with the lists it reads, using the same tag your publish webhook clears. For example, if your /api/revalidate route clears the list tag when an item is published, a single webhook refreshes both REST and GraphQL reads of that list:
// src/app/api/revalidate/route.ts (excerpt)
revalidateTag(`agility-content-${referenceName.toLowerCase()}-${languageCode}`, "max")
See Webhooks.
Commit schema.graphql and the __generated__/ folder, then add this step to CI:
- name: GraphQL types match the committed schema
run: npx graphql-codegen --check
--check fails when a query changed but its types weren't regenerated. Together with tsc --noEmit, it catches a model change that breaks a query in the pull request, before it reaches production.
Exclude src/lib/graphql/__generated__/ from ESLint. It's generated code, and linting it only adds noise.
ag_pages returns each zone's component fields as JSON, so keep rendering pages with the Agility Next.js SDK and use typed GraphQL for content lists.fragmentMasking: true) so each component can only read the fields it declared.