GraphQL-Based Code Generation — Eliminating All Boilerplate

Fifth in a series about migrating from legacy architectures to a modern Nuxt 4 stack. Covers architecture, code generation, performance, infrastructure, and the automation philosophy behind every decision.


The Old Way: Manual Everything

In most REST-based frontends used in large enterprise applications, adding a new API call follows the same ritual:

  1. Write the fetch call with the correct URL, headers, and query parameters
  2. Define a TypeScript interface for the response
  3. Write a mapping function to transform the raw response into the interface
  4. Handle loading state, error state, and retry logic
  5. Wire it into the component’s reactive state

Multiply that by 50 endpoints and a substantial chunk of the codebase becomes pure boilerplate — code that adds no business value and only exists to glue the frontend to the backend.

This hand-written translation layer is also a reliable source of bugs. A renamed field in the backend silently breaks the frontend. A new enum value slips in and produces a runtime error. A date format change suddenly corrupts form displays. These failures often surface only in production, because the TypeScript compiler cannot validate your types against a schema that lives elsewhere.


The New Way: Write GraphQL, Get Everything

A more modern workflow removes the manual layer entirely:

flowchart TB
  A["Developer writes<br/>.graphql query file"] -->|"yarn gen"| B[Code Generator]
  B --> B1["useProductOffersQuery.ts<br/>(reactive composable<br/>useAsyncData + loading + error<br/>typed variables & result)"]
  B --> B2["useProductOffersClient.ts<br/>(low-level client<br/>manual execution,<br/>no reactive wrapper)"]
  B --> B3["ProductOffersDocument.ts<br/>(typed document node<br/>operation + types<br/>for Apollo)"]
  B1 --> C["Developer uses in component<br/>const { data, pending, error } = useProductOffersQuery({ zip })<br/>data is fully typed"]

The developer writes a .graphql file. The generator turns it into fully typed composables that are auto-imported — no manual imports. The TypeScript compiler now has full knowledge of every field, every type, and every nullable boundary.


Three Files Per Operation

For each GraphQL operation, the generator emits three files, each optimized for a different usage pattern.

1. The Reactive Composable (useProductOffersQuery.ts)

This is the primary API for Vue components. It wraps Nuxt’s useAsyncData around the GraphQL operation and provides:

  • Typed variables — the function signature accepts exactly the variables the query defines
  • Typed resultdata.value matches the query’s return shape, including nullability from the schema
  • SSR-safe execution — runs on the server during SSR, serializes the result into the Nuxt payload, and hydrates on the client without a second fetch
  • Reactive refetching — changing a variable automatically triggers a fresh query
  • Loading and error statespending, error, and status refs are handled for you

2. The Client Composable (useProductOffersClient.ts)

For imperative flows — form submissions, button clicks, conditional fetches — where you want to trigger the query manually instead of reactively:

const { execute } = useProductOffersClient()

async function onSubmit() {
  const result = await execute({ zip: '10115' })
  // result is fully typed
}

3. The Document Node (ProductOffersDocument.ts)

The raw typed document node used for advanced integration points — custom Apollo links, direct cache access, or any place that expects a DocumentNode.


Rich Text Fragment Generation

Headless CMS rich text fields are one of the trickiest parts of code generation. In GraphQL, rich text is not a plain string — it is a deeply nested JSON tree with typed links to entries and assets.

The key problem: each content type’s rich text field has its own link types. A SectionText field references SectionTextLinks. A MediaDescription field uses MediaDescriptionLinks. Structurally they are identical, but the type names differ, which prevents you from writing a single reusable fragment by hand.

The solution is a custom generator that identifies rich text fields by their structural pattern — a type that has both json: JSON! and links: [TypeName]Links! — and then emits shared fragments automatically:

flowchart TB
  subgraph Detection
    D["Type with<br/>json: JSON!<br/>links: SectionTextLinks!"] --> E["Recognized<br/>as rich text field"]
    E --> F["Generate<br/>sectionText.fragment.graphql"]
  end

  subgraph "Generated fragment includes"
    F --> G["json<br/>(rich text content tree)"]
    F --> H[links]
    H --> I[assets]
    I --> I1["block<br/>(images, files)"]
    I --> I2["hyperlink<br/>(linked assets)"]
    H --> J[entries]
    J --> J1["inline<br/>(embedded entries)"]
    J --> J2["hyperlink<br/>(linked entries)"]
    J --> J3["block<br/>(embedded entry blocks)"]
    H --> K["resources<br/>(external resources)"]
  end

Queries can then pull in these fragments:

#import "./fragments/sectionText.fragment.graphql"

query PageContent($path: String!) {
  page(path: $path) {
    title
    body { ...SectionText }   # ← comprehensive rich text, one line
  }
}

This removes hundreds of lines of duplicated GraphQL across dozens of queries. When the rich text schema evolves — for example, to support a new embedded entry type — you update the generator once and every query benefits.


Possible Types Introspection

GraphQL unions and interfaces require the client to know which concrete types implement them. Without that knowledge, Apollo’s cache cannot resolve __typename correctly and fragment matching breaks in subtle ways.

At build time, the generator runs an introspection query against each subgraph and produces a possibleTypes map:

// Generated: cms-possible-types.ts
export const cmsPossibleTypes = {
  Entry: ['Page', 'Section', 'Teaser', 'FAQ', 'NavigationItem', ...],
  Asset: ['Asset'],
  _Node: ['Page', 'Section', ...],
}

This map is passed into Apollo’s InMemoryCache configuration. Fragment matching becomes deterministic and correct, with no runtime probing required — the types are known ahead of time.

flowchart LR
  A[GraphQL Subgraph Schema] --> B["Introspection Query<br/>(build time)"]
  B --> C[cmsPossibleTypes Map]
  C --> D["Apollo InMemoryCache<br/>possibleTypes config"]
  D --> E["Correct fragment matching<br/>(__typename resolved)"]

The Developer Experience Difference

AspectManual REST ApproachGenerated GraphQL
New API callWrite fetch + interface + mapper + error handlingWrite .graphql file, run yarn gen
Field renameSilent runtime breakageTypeScript compiler error at build time
New fieldUpdate interface, mapper, and componentAdd field to .graphql file, regenerate
Type safetyHand-written, often staleGenerated from schema, always in sync
SSR handlingCustom useAsyncData wrapper per callBuilt into generated composable
Import managementManual imports in every componentAuto-imported by Nuxt
Rich text queriesCopy-paste 30 lines per fieldSingle fragment reference
flowchart TB
  subgraph ManualREST["Manual REST Approach"]
    MR1["New API call:<br/>fetch + interface + mapper"] 
    MR2["Field rename:<br/>runtime breakage"]
    MR3["Type safety:<br/>hand-written types"]
  end

  subgraph GenGQL["Generated GraphQL"]
    GG1["New API call:<br/>write .graphql + yarn gen"]
    GG2["Field rename:<br/>TS compiler error"]
    GG3["Type safety:<br/>schema-driven"]
  end

  MR1 --> MR2 --> MR3
  GG1 --> GG2 --> GG3

Build-Time vs. Runtime Generation

All generation is build-time. The generator outputs TypeScript files that the compiler type-checks, the bundler tree-shakes, and your IDE fully understands.

flowchart TB
  A["Schema Introspection<br/>(per subgraph)"] --> B[GraphQL Code Generator]
  B --> C["TypeScript types<br/>from schema"]
  B --> D["Composables<br/>from .graphql files"]
  B --> E[Rich text fragments]
  B --> F[Possible types map]
  C --> G[TypeScript Compilation]
  D --> G
  E --> G
  F --> G
  G --> H[Vite Bundle]
  H --> I[Tree-shakes unused operations]
  G --> J[Catches schema-query mismatches]

During development (yarn dev), the generator runs in watch mode on .graphql files. Save a query file → codegen re-runs → new types appear → IDE autocompletion updates. The feedback loop is effectively instantaneous.


Lessons Learned

The schema is the single source of truth

When every frontend type, field, and nullable boundary is derived from the backend schema, the schema becomes the contract. Breaking changes surface as compile-time errors instead of 2 AM production incidents. This is not just convenience — it is about ensuring correctness.

flowchart LR
  S["GraphQL Schema<br/>(single source of truth)"] --> G["Generated Types & Composables"]
  G --> F[Frontend Code]
  S -. breaking change .-> G
  G -. compile-time error .-> F

Rich text is the hardest part of CMS integration

If you are integrating a headless CMS via GraphQL, budget serious effort for rich text. The deeply nested trees and type-specific link structures present a code generation challenge that generic tools like graphql-codegen do not handle out of the box. Custom generators that detect and encapsulate rich text patterns pay off immediately.

Auto-imports change the economics of small composables

When every generated composable is auto-imported, the marginal cost of a new one drops to zero. That encourages the right level of granularity — one query per use case, instead of bloated, multi-purpose queries that fetch everything. Smaller queries lead to smaller cache entries, faster invalidation, and better tree-shaking.

flowchart TB
  A["Generate composable<br/>per .graphql operation"] --> B[Nuxt auto-imports]
  B --> C["Zero-cost usage<br/>in components"]
  C --> D[Many small, focused queries]
  D --> E[Smaller cache entries]
  D --> F[Better tree-shaking]
  D --> G[Faster invalidation]

Generation should be invisible in the happy path

The best code generation dissolves into the developer experience. You write a .graphql file, hit save, and your IDE instantly offers typed autocompletion. Codegen should run automatically in dev and as part of CI. If developers must remember to run a command manually, they will forget.

sequenceDiagram
  participant Dev as Developer
  participant IDE as IDE
  participant Gen as Code Generator
  participant CI as CI Pipeline

  Dev->>IDE: Edit .graphql file
  IDE->>Gen: File change detected
  Gen-->>IDE: Regenerate types & composables
  IDE-->>Dev: Updated autocomplete & types

  CI->>Gen: Run codegen on push
  Gen-->>CI: Generated artifacts
  CI-->>CI: Type-check & build with generated code

What’s Next

  • Article 4: Nuxt-Based Code Generation — The GQLT and I18NT Modules — Build-time code generation that turns YAML and GraphQL into auto-imported, type-safe APIs.
  • Article 5: Nuxt and Headless CMS — Why It’s Hard and How We Made It Work — Content-driven routing, dynamic component rendering, and the preview system.
  • Article 6: Performance Optimization — Chasing That 100% Lighthouse Score — Multi-tier caching, deferred hydration, and the performance techniques that add up to near-perfect scores.

Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.

Categories: