- Date
Type-Safe WPGraphQL Queries with GraphQL Code Generator
This revisits GraphQL Code Generator for WPGraphQL from May 2021, which is still up for reference. Codegen has changed enough since then that almost none of the original instructions still apply: the
codegen.ymlconfig, the plugin list, and theinitwizard walkthrough have all been superseded by the client preset. This one is written against GraphQL Code Generator v7 and WPGraphQL v2.23. I have also reversed the original’s advice about introspection, which is worth reading even if you already know codegen well.
Your WordPress site already knows the exact shape of every field WPGraphQL can return. Your TypeScript front end, by default, knows none of it. GraphQL Code Generator closes that gap: point it at your schema and your queries, and it writes the types for you.
The payoff is not just autocomplete. It is that renaming a field in a WordPress plugin becomes a build error in your front end instead of undefined in production.
What changed since the 2021 post
The client preset replaced the plugin pick-and-mix. The original post walked through the init wizard, choosing typescript and typescript-operations plugins and wiring up a codegen.yml. The modern setup is one preset and a TypeScript config file, and it generates typed documents rather than loose type aliases.
Config moved to codegen.ts. You get type checking and autocomplete on your codegen config, which matters more than it sounds like it should.
You should not open up public introspection to make this work. More on that next, because it is the one piece of the 2021 post I would actively warn people off.
Getting the schema without exposing it
Codegen needs to read your schema. The 2021 post told you to turn on public introspection to let it do that. I would not give that advice today.
WPGraphQL limits introspection to authenticated requests in production and staging environments by default. That default exists for a reason: introspection publishes a complete map of your API, including every field any plugin has registered, to anyone who asks. It is not a vulnerability by itself, but it is a large amount of information about your stack that you do not have to give away.
There are two better options.
Generate a schema file with WP-CLI. WPGraphQL ships a command for exactly this:
wp graphql generate-static-schema --output=./schema.graphql
Commit the result. Codegen reads it from disk, your build does not depend on the network, and CI produces identical output every run. Regenerate it when you change plugins, and the diff shows you precisely what changed in your API, which is useful on its own.
(Pass --output explicitly. The command’s documentation says it defaults to the plugin directory, but it actually writes to your system temp directory.)
Or introspect with credentials. If you would rather read the live schema, authenticate the request instead of opening it to everyone:
schema: [ { [process.env.WPGRAPHQL_URL!]: { headers: { Authorization: `Basic ${Buffer.from( `${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}` ).toString("base64")}`, }, }, },]
WordPress Application Passwords are core, so this needs no extra plugin. Use a read-only account.
Setting up
npm i -D @graphql-codegen/cli @graphql-codegen/client-preset
Then codegen.ts in your project root:
import type { CodegenConfig } from "@graphql-codegen/cli"const config: CodegenConfig = { schema: "./schema.graphql", documents: ["src/**/*.{ts,tsx}", "!src/gql/**/*"], ignoreNoDocuments: true, generates: { "./src/gql/": { preset: "client", }, },}export default config
A few things worth calling out. The trailing slash on ./src/gql/ is required, the client preset generates a directory rather than a file. Excluding src/gql/**/* from documents stops codegen from reading its own output. And ignoreNoDocuments: true keeps watch mode from erroring on the pass where you have deleted a query and not yet written its replacement.
Add the scripts:
{ "scripts": { "codegen": "graphql-codegen", "codegen:watch": "graphql-codegen --watch" }}
Writing queries
This is the part that feels different from 2021. Instead of importing generated types by name, you wrap the query itself in the generated graphql() function:
import { graphql } from "@/gql"export const PostBySlug = graphql(` query PostBySlug($slug: ID!) { post(id: $slug, idType: SLUG) { databaseId title content date author { node { name avatar { url } } } } }`)
Run npm run codegen and PostBySlug becomes a TypedDocumentNode carrying both its result type and its variables type. You never import PostBySlugQuery by hand, and you cannot import a type that has drifted from the query it describes, because they are the same object.
Executing typed documents
TypedDocumentNode is a standard, so this works with Apollo, urql, graphql-request, or plain fetch. Here is the fetch version, which needs no client library:
import type { TypedDocumentNode } from "@graphql-typed-document-node/core"
import { print } from "graphql"
export async function wpQuery(
document: TypedDocumentNode,
variables?: TVariables
): Promise {
const res = await fetch(process.env.WPGRAPHQL_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query: print(document), variables }),
next: { revalidate: 60 },
})
if (!res.ok) {
throw new Error(`WPGraphQL responded ${res.status}`)
}
const json = await res.json()
if (json.errors?.length) {
throw new Error(json.errors[0].message)
}
return json.data as TResult
}
Call it and the types follow:
const data = await wpQuery(PostBySlug, { slug })// ^? { post: { title: string; content: string; ... } | null }data.post?.titel// ~~~~~ Property 'titel' does not exist. Did you mean 'title'?
Passing the wrong variables is a compile error too, and so is forgetting them.
Fragment masking
The client preset generates fragment types that are deliberately opaque: a component that declares a fragment can read those fields, and a component that does not, cannot, even if the data happens to be sitting in the object at runtime.
import { graphql, useFragment, type FragmentType } from "@/gql"
export const AuthorByline = graphql(`
fragment AuthorByline on User {
name
avatar { url }
}
`)
export function Byline(props: { author: FragmentType }) {
const author = useFragment(AuthorByline, props.author)
return {author.name}
}
This stops the failure where you delete a field from one query and break a component three directories away that was quietly relying on it. If it feels like more ceremony than your project needs, turn it off with presetConfig: { fragmentMasking: false }, which gets you the typed documents without the indirection.
When to regenerate
Whenever the schema changes, which in WordPress means whenever you activate, deactivate, or update a plugin that registers GraphQL types. That is a wider surface than most teams expect, and it is the reason committing schema.graphql is worth the small friction: the change shows up in a pull request instead of as a runtime surprise.
A reasonable setup:
npm run codegen:watchwhile developingnpm run codegeninprebuild, so a stale commit cannot ship- In CI, regenerate and fail if the working tree is dirty, which catches anyone who edited a query without running codegen
- run: npm run codegen- run: git diff --exit-code
Wrapping up
The argument for codegen has not changed since 2021. WPGraphQL knows the shape of your data, and typing it by hand is work a machine does better and keeps doing correctly after you stop paying attention.
What has changed is that the setup got shorter and the advice got safer. One preset instead of a plugin list, a typed config file instead of YAML, typed documents instead of imported aliases, and a committed schema file instead of introspection open to the world.
If you are upgrading from the setup in the 2021 post, the migration is: delete codegen.yml, install the client preset, write codegen.ts, and replace imported query types with graphql() calls. It takes an afternoon on a mid-sized app and you will delete more lines than you add.