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:
- GraphQL composables that need Nuxt-specific wrappers — auto-imports,
useAsyncDataintegration, client selection, SSR payload handling - 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:
- 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.
- 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 positionalstringparameter{@:currency:{0}}→ recognized as a currency formatter, parameter typed asnumber
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:
| Aspect | Build-Time Generation | Runtime Magic |
|---|---|---|
| Type safety | Full — IDE and compiler see everything | Partial — types often degrade to any or require manual casting |
| Performance | Zero runtime cost — emitted code is plain TypeScript | Runtime overhead from Proxies, reflection, dynamic resolution |
| Debugging | Generated files are readable and debuggable | Stack traces wind through Proxy traps and dynamic resolvers |
| Tree-shaking | Unused generated code is removed by the bundler | Runtime systems are opaque to tree-shakers |
| Discoverability | IDE autocompletion exposes all APIs | Developers 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.





