# Typed GraphQL with TypeScript and Next.js

> Source: https://agilitycms.com/docs/developers/typed-graphql-typescript-nextjs

This guide shows how to query Agility's [GraphQL API](https://agilitycms.com/docs/developers/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:

- **GraphQL Code Generator** with the **client preset** generates a type for each query you write.
- A **committed schema snapshot** keeps builds reproducible and checkable in CI, with no network call at build time.
- **Plain `fetch`** in server components sends the queries. No GraphQL client library runs in the browser or on the server.
- **Next.js caching** (`"use cache"` + cache tags) is cleared by Agility's publish webhook.

## Why typed queries

Hand-written interfaces drift. A model field gets renamed, the interface doesn't, and the page quietly renders nothing. With generated types:

- The query is the single source of truth for the data's shape. There's no interface to keep in sync.
- A query that asks for a field your model doesn't have fails `graphql-codegen`.
- Code that reads a field the query didn't request fails `tsc`.
- Nullability is real. The schema marks fields nullable, so TypeScript makes you handle a missing value instead of assuming it's there.

## 1. Install

```bash
npm i -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-typed-document-node/core graphql
```

All four are development dependencies.

## 2. Snapshot the schema

Agility generates the schema from your content models. Save it to a file and commit it, so type generation never depends on the network:

```typescript
// 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.

## 3. Configure codegen

```typescript
// 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`:

```json
"graphql:schema": "tsx node/graphql-schema.ts",
"graphql:codegen": "graphql-codegen"
```

## 4. A typed query runner with caching

```typescript
// 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.

## 5. Write a query and use it

```typescript
// 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.

## 6. Keep cache and content in sync

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:

```typescript
// src/app/api/revalidate/route.ts (excerpt)
revalidateTag(`agility-content-${referenceName.toLowerCase()}-${languageCode}`, "max")
```

See [Webhooks](https://agilitycms.com/docs/developers/webhooks).

## 7. Check in CI

Commit `schema.graphql` and the `__generated__/` folder, then add this step to CI:

```yaml
- 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.

> [!TIP]
> Exclude `src/lib/graphql/__generated__/` from ESLint. It's generated code, and linting it only adds noise.

## Notes

- **Page components:** `ag_pages` returns each zone's component fields as `JSON`, so keep rendering pages with the [Agility Next.js SDK](https://agilitycms.com/docs/nextjs) and use typed GraphQL for content lists.
- **Fragments:** as queries grow, define a fragment per component and turn fragment masking on (`fragmentMasking: true`) so each component can only read the fields it declared.
- **gql.tada** is an alternative that infers types in the TypeScript compiler with no generation step. Codegen is used here because its output is plain files that are easy to review, and it doesn't depend on an editor plugin.

---

## Related pages

- [Content Fetch API](https://agilitycms.com/docs/developers/content-fetch-api.md)
- [Content Sync API](https://agilitycms.com/docs/developers/content-sync-api.md)
- [Content Management API](https://agilitycms.com/docs/developers/content-management-api.md)
- [GraphQL API](https://agilitycms.com/docs/developers/graphql-api.md)
- [CLI - CI/CD Integration Guide](https://agilitycms.com/docs/developers/cli-ci-cd-integration-guide.md)
- [Testing on Swagger](https://agilitycms.com/docs/developers/testing-on-swagger.md)
- [GraphQL & Rest API Filtering](https://agilitycms.com/docs/developers/graphql-operators.md)
- [AgilityCLI (Command Line Interface)](https://agilitycms.com/docs/developers/cli.md)
- [Personal Access Tokens](https://agilitycms.com/docs/developers/personal-access-tokens.md)
- [Content Delivery & CDN Architecture](https://agilitycms.com/docs/developers/content-delivery-and-cdn-architecture.md)
- [Image Transformation Reference](https://agilitycms.com/docs/developers/image-transformation-reference.md)
- [Fetch API Status Codes and Caching](https://agilitycms.com/docs/developers/fetch-api-status-codes-and-caching.md)
- [Field Types and What the APIs Return](https://agilitycms.com/docs/developers/field-types-api-reference.md)
- [Content Sync Explained](https://agilitycms.com/docs/developers/content-sync-explained.md)
- [Agility CLI Command Reference](https://agilitycms.com/docs/developers/cli-reference.md)
- [CLI Reference: agility pull](https://agilitycms.com/docs/developers/cli-pull.md)
- [CLI Reference: agility sync and push](https://agilitycms.com/docs/developers/cli-sync.md)
- [CLI Reference: agility reverse-sync](https://agilitycms.com/docs/developers/cli-reverse-sync.md)
- [CLI Reference: agility workflows](https://agilitycms.com/docs/developers/cli-workflows.md)
- [CLI Reference: agility login and logout](https://agilitycms.com/docs/developers/cli-login-logout.md)
- [Test Your Agility Integration in CI](https://agilitycms.com/docs/developers/test-agility-integration-in-ci.md)
- [CLI Reference: agility verify](https://agilitycms.com/docs/developers/cli-verify.md)
- [Create a Personal Access Token](https://agilitycms.com/docs/developers/create-personal-access-token.md)

Index of every docs page: https://agilitycms.com/docs/llms.txt
