Nuxt-Based Code Generation — The GQLT and I18NT Modules

Seventh in a series about migrating from legacy architectures to a modern Nuxt 4 stack.


Beyond External Generators

The previous article discussed GraphQL code generation using external tools like graphql-codegen. Those tools are excellent for generating types and composables from .graphql files, but in a large enterprise application they break down in two important areas:

  1. GraphQL composables that need Nuxt-specific wrappers — auto-imports, useAsyncData integration, client selection, SSR payload handling
  2. Internationalization types — translation files are YAML/JSON, not GraphQL, and there is no standard way to generate TypeScript types from translation structures.

Both gaps are closed by Nuxt modules that generate code at build time — code that is emitted into .nuxt/, auto-imported, and fully type-checked in the IDE.


GQLT — The GraphQL Toolkit Module

GQLT is a Nuxt module that watches .graphql files, parses them, and generates typed composables into .nuxt/gqlt/. These composables are registered as auto-imports — developers never write an import.

How It Works

flowchart TB
  A["Developer saves<br/>queries/cms/pageByPath.graphql"] --> B["GQLT Module<br/>(build time / dev watcher)"]

  subgraph "GQLT Module"
    B1[Parse .graphql file]
    B2[Resolve types from schema introspection]
    B3[Generate composable files]
    B4[Register all three as auto-imports]
  end

  B --> B1 --> B2 --> B3 --> B4

  subgraph "Generated composables in .nuxt/gqlt/"
    C1["usePageByPathQuery.ts<br/>(reactive, useAsyncData-based)"]
    C2["usePageByPathClient.ts<br/>(imperative, manual execution)"]
    C3["usePageByPathEntry.ts<br/>(CMS-specific: fetch-or-use)"]
  end

  B3 --> C1
  B3 --> C2
  B3 --> C3

  C1 & C2 & C3 --> D["Developer uses in component<br/>const { data } = usePageByPathQuery({ path: route.path })<br/>// no import needed, fully typed"]

The crucial difference from external generators: GQLT emits Nuxt-native composables that understand the runtime. The reactive composable wraps useAsyncData with the correct cache key, chooses the right GraphQL client (for example, CMS vs. backend vs. default), and handles SSR payload serialization.

The Entry Composable Pattern

The third generated file — use*Entry.ts — addresses a pattern common in CMS-driven or catalog-driven applications. A page-level query retrieves a collection of content entries or products, and a child component needs just one entry. Without the entry composable, the child must either:

  • Re-fetch the entire collection (wasteful)
  • Accept the entry via props (tight coupling)
  • Traverse a deep structure like data.value.collection.items (brittle)

The entry composable provides a typed accessor that pulls a specific entry from an already-fetched collection, with proper null handling and type narrowing. If the data is already present in the SSR payload, it reuses it — no extra query.

flowchart LR
  A[Page-level component] --> B["usePageByPathQuery<br/>(fetch collection)"]
  B --> C["SSR payload<br/>cached collection"]

  C --> D[Child component]

  subgraph "Without entry composable"
    D --> E1[Re-fetch collection]
    D --> E2[Accept entry via props]
    D --> E3["Traverse deep structure<br/>data.value.collection.items"]
  end

  C --> F["usePageByPathEntry<br/>(entry composable)"]
  F --> G["Child component gets<br/>typed single entry<br/>no extra query"]

I18NT — Typed Internationalization

Most Vue projects wire up vue-i18n with dotted key strings like t('common.buttons.submit'). It works, but has two serious drawbacks:

  1. No type safety. A typo in the key becomes a runtime error and leaks the raw key to the UI. Renaming a key requires global find-and-replace with no compiler support.
  2. Poor discoverability. To find the right key, you have to open YAML files, scan nested structures, and mentally map them to code. There is no way to explore translations directly from the editor.

I18NT fixes both by generating a TypeScript interface tree that mirrors the translation file structure, then wrapping vue-i18n in a JavaScript Proxy chain that turns translation keys into a navigable, type-safe object with full IDE autocomplete.

The Generation Pipeline

flowchart LR
  A["Translation source<br/>i18n/locales/de/common.yaml"] --> B[Build-time generator]

  subgraph "YAML structure"
    A1[buttons.submit: 'Absenden']
    A2[buttons.cancel: 'Abbrechen']
    A3[validation.required: 'Pflichtfeld']
    A4[validation.email: 'Ungültige E-Mail']
    A5["currency.monthly: '{@:currency:{0}}/Monat'"]
  end

  A --> A1
  A --> A2
  A --> A3
  A --> A4
  A --> A5

  B --> C[Generated .nuxt/i18nt/types.ts]

  subgraph "Generated TypeScript interface"
    C1["interface CommonTranslations {<br/>buttons: { submit: string; cancel: string }<br/>validation: { required: string; email: string }<br/>currency: { monthly: (amount: number) => string }<br/>}"]
  end

  C --> C1

The Proxy Chain

These generated types feed into a Proxy-based wrapper around vue-i18n:

sequenceDiagram
  participant Dev as Developer
  participant T as t proxy root
  participant P as Proxy handler
  participant I18N as vue-i18n

  Dev->>T: t.common.buttons.submit
  T->>P: access 'common'
  P->>P: build key "common"
  P->>P: access 'buttons'
  P->>P: build key "common.buttons"
  P->>P: access 'submit'
  P->>P: build key "common.buttons.submit"
  P->>I18N: t("common.buttons.submit")
  I18N-->>Dev: "Absenden"

  Note over Dev,P: IDE autocompletion & TS types<br/>for each property access

At runtime, the Proxy chain is just a handful of property accesses. For the developer, it delivers two big wins: type safety that catches mistakes at compile time, and discoverability that lets you browse translations via t. and IntelliSense. A developer who has never opened the YAML files can still find the right key without leaving the component.

Format Annotations

Translation strings with parameters (such as {@:currency:{0}}/Monat) are analyzed at build time. The generator encodes parameter types into the generated interface:

  • {0} → the function signature includes a positional string parameter
  • {@:currency:{0}} → recognized as a currency formatter, parameter typed as number

As a result, t.common.currency.monthly(29.99) is type-checked: passing a string where a number is expected becomes a compile error.

flowchart TB
  A["Translation string<br/>{@:currency:{0}}/Monat"] --> B[Build-time analyzer]

  B --> C["Detect placeholder {0}"]
  B --> D[Detect currency formatter annotation]

  C --> E[Infer parameter position 0]
  D --> F[Infer parameter type number]

  E & F --> G["Generate TypeScript<br/>monthly(amount: number) => string"]

  G --> H["Developer calls<br/>t.common.currency.monthly(29.99)<br/>✓ type-checked"]
  G --> I["Developer calls<br/>t.common.currency.monthly('29.99')<br/>✗ compile error"]

The Plop Scaffolding Generator

In a large Nuxt-based codebase with dozens of internal modules, consistency is non-negotiable. Every module follows the same layout:

flowchart TB
  subgraph "modules/my-module/"
    A["index.ts<br/>defineNuxtModule()"]
    subgraph runtime
      B["composables/<br/>auto-imported composables"]
      C["components/<br/>auto-imported components"]
      D["server/<br/>Nitro handlers and plugins"]
      E["plugins/<br/>Nuxt plugins"]
    end
    F["types.d.ts<br/>module type declarations"]
    G["README.md<br/>documentation"]
  end

The plop generator scaffolds this structure with a single command:

$ yarn plop
? Module name: my-feature
? Include server handlers? Yes
? Include DevTools tab? No

✓ Created modules/my-feature/index.ts
✓ Created modules/my-feature/runtime/composables/useMyFeature.ts
✓ Created modules/my-feature/runtime/server/plugin.ts
✓ Created modules/my-feature/README.md
✓ Created modules/my-feature/types.d.ts

The templates live as Handlebars files in .plop-templates/, encoding the application’s conventions. New developers do not need to memorize the module anatomy — the generator enforces it.


Why Build-Time Generation Beats Runtime Magic

A common alternative to code generation is runtime magic — Proxies, dynamic imports, and reflection that resolve behavior at execution time. Build-time generation has three clear advantages:

AspectBuild-Time GenerationRuntime Magic
Type safetyFull — IDE and compiler see everythingPartial — types often degrade to any or require manual casting
PerformanceZero runtime cost — emitted code is plain TypeScriptRuntime overhead from Proxies, reflection, dynamic resolution
DebuggingGenerated files are readable and debuggableStack traces wind through Proxy traps and dynamic resolvers
Tree-shakingUnused generated code is removed by the bundlerRuntime systems are opaque to tree-shakers
DiscoverabilityIDE autocompletion exposes all APIsDevelopers must memorize naming conventions
flowchart LR
  subgraph BuildTime[Build-Time Generation]
    BT1[Code generators in Nuxt modules]
    BT2[Readable generated TS files]
    BT3["Types visible to IDE & compiler"]
  end

  subgraph Runtime[Runtime Magic]
    RT1["Proxies & reflection at runtime"]
    RT2[Dynamic imports / resolution]
    RT3[Types often degraded to any]
  end

  BT1 --> BT2 --> BT3
  RT1 --> RT2 --> RT3

  BT3 --> A["Strong type safety<br/>and better DX"]
  RT3 --> B["Weaker type safety<br/>and harder debugging"]

  A --> C["Preferred for Nuxt-integrated<br/>enterprise codebases"]
  B --> C

I18NT is intentionally hybrid: build-time generation for types (for full IDE support) plus a minimal runtime Proxy for ergonomics (t.common.buttons.submit instead of t('common.buttons.submit')). The Proxy is a thin wrapper around vue-i18n, with negligible overhead, but it turns translation files into a navigable API.


The Combined Effect

GQLT and I18NT are not the only generators in play. In a typical enterprise Nuxt stack, additional modules often follow the same pattern:

  • Introspect Forms — uses generated GraphQL schema introspection types to automatically pick the right input component for each field (text inputs, selects, radios, date pickers) based on field type and enum values. Developers only supply overrides — layout, visibility conditions, custom components — not the field definitions themselves.
  • Validation Rules — generates client-side validation rules from a backend validation framework (for example, a .NET or Node validation library that defines rules on backend models). The generated rules guarantee that frontend and backend enforce the same constraints without duplication.

Together with the GraphQL code generator from the previous article, these modules form a generation pipeline where each layer builds on the previous one:

flowchart TB
  subgraph "Developer-written sources"
    A1[queries/cms/page.graphql]
    A2[queries/backend/offers.graphql]
    A3[i18n/locales/de/common.yaml]
    A4[i18n/locales/de/forms.yaml]
    A5[Backend GraphQL schema]
  end

  subgraph "Generators"
    G1[GQLT module]
    G2[I18NT module]
    G3[Forms / validation generators]
  end

  A1 --> G1
  A2 --> G1
  A3 --> G2
  A4 --> G2
  A5 --> G3

  subgraph "Generated artifacts (auto-imported)"
    B1[usePageQuery.ts]
    B2[useOffersQuery.ts]
    B3[typed t.common.*]
    B4[typed t.forms.*]
    B5[Form field definitions]
    B6[Validation rules]
    B7[TypeScript interfaces for API responses]
    B8[useAsyncData wrappers]
  end

  G1 --> B1
  G1 --> B2
  G1 --> B7
  G1 --> B8

  G2 --> B3
  G2 --> B4

  G3 --> B5
  G3 --> B6

  subgraph "What the developer never writes"
    N1[TS interfaces for API responses]
    N2[Import statements for composables]
    N3[Translation key strings]
    N4[Manual useAsyncData wrappers]
    N5[Form field configuration]
    N6[Client-side validation logic]
    N7[Error handling boilerplate]
  end

  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N1
  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N2
  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N3
  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N4
  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N5
  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N6
  B1 & B2 & B3 & B4 & B5 & B6 & B7 & B8 --> N7

The code a developer writes is only a slice of what the system runs. The rest is generated, type-checked, and auto-imported.


Lessons Learned

Nuxt modules are the right abstraction for build-time generation

Nuxt modules run at build time, see the full Nuxt configuration, can declare auto-imports, and can watch files — they are a natural home for code generators. External tools like graphql-codegen handle schema-level generation; Nuxt modules handle framework-level integration.

flowchart LR
  A["Nuxt module<br/>(build-time)"] --> B[Access Nuxt config]
  A --> C[Declare auto-imports]
  A --> D[Watch project files]
  A --> E[Emit generated code into .nuxt/]

  subgraph "External tools (e.g. graphql-codegen)"
    X1[Schema-level types]
    X2[Operation-level TS types]
  end

  X1 --> F[Feed into Nuxt modules]
  X2 --> F

  F --> E

Generated code should be inspectable

Every generated file in .nuxt/gqlt/ and .nuxt/i18nt/ is readable TypeScript. Developers can open them, understand them, and step through them in a debugger. That transparency builds trust and keeps debugging routine.

flowchart TB
  A[Generator runs at build time] --> B["Emit .nuxt/gqlt/*.ts<br/>.nuxt/i18nt/*.ts"]
  B --> C[Developer opens generated file]
  C --> D[Readable TS with clear structure]
  D --> E[Step through in debugger]
  E --> F[Trust in generation pipeline]

The Proxy pattern is underused in TypeScript projects

JavaScript Proxies are powerful but often avoided because they are hard to type. I18NT’s approach — generating the type interfaces at build time and applying them to a Proxy at runtime — delivers Proxy ergonomics with static type safety. This pattern works well far beyond internationalization.

flowchart LR
  A["Domain schema<br/>(YAML, GraphQL, etc.)"] --> B[Build-time type generator]
  B --> C[Generated TS interfaces]

  C --> D[Typed Proxy wrapper]
  D --> E["Ergonomic API<br/>(e.g. t.common.buttons.submit)"]
  C --> F[Compiler-enforced types]

  E & F --> G["Proxy ergonomics<br/>+ static type safety"]

Scaffolding generators enforce consistency at scale

At 35+ modules, consistency is a necessity. The plop generator makes sure every module follows the same patterns and ships with the same documentation structure. New developers (human or AI) can orient themselves in any module once they recognize the pattern.

flowchart TB
  A[Developer runs plop] --> B["Answer prompts<br/>(name, server handlers, DevTools tab)"]
  B --> C["Apply Handlebars templates<br/>from .plop-templates/"]
  C --> D[Create module folder structure]

  D --> E[Consistent layout across 35+ modules]
  E --> F[Faster onboarding]
  E --> G["Lower cognitive load<br/>when navigating codebase"]

What’s Next

  • 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.
  • Article 7: Type-Safe Form Generation from GraphQL Introspection — How the backend schema drives form rendering, validation, and field configuration without any frontend duplication.

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

Categories: