Nuxt-Based Code Generation — Generating the Entry Points Developers Would Otherwise Write
Hero image credit: Photo by Aleksandra Żmuda on Pexels
Part 7 of the Nuxt and .NET series.
Part 5 generated types from the GraphQL schema. Part 6 established what a module owns and when its code runs. This article joins the two: two modules that generate the entry points developers use — a composable per query and a typed object for translations — so that the repetitive wiring between a contract and a component is produced by the build rather than typed out again per feature.
The examples continue the fictional document application. Module names, file layouts and generated shapes are one application’s conventions, not framework defaults.
Table of Contents
- What Part 5 Left Developers Doing by Hand
- A Module That Watches the Query Files
- Choosing the Client and the Cache Key
- Reusing an Entry That Was Already Fetched
- Translation Keys the Compiler Knows About
- Parameters and Formatters in Translations
- Scaffolding a New Module
- How Generated Output Goes Wrong
- The Workflow End to End
- What’s Next
What Part 5 Left Developers Doing by Hand
Part 5’s pipeline produces types and a typed document for each operation. That is the contract solved. It is not the call solved.
Adding a query to the revision-comparison page from Part 6 still means: importing the document and its types, calling useAsyncData around the Apollo client, inventing a cache key that includes the variables and anything else that varies the result, picking which client to use because the CMS and the application API are reached differently, and handling the SSR payload so the browser does not refetch. Then the new page needs three labels, which means opening a YAML file, inventing dotted key strings, and hoping nobody typos documnet.
Every one of those steps is mechanical, and every one of them is a place where two developers will make different choices. That is what these two modules generate — not the contract, the ergonomics around it — and the driving question is whether the generated entry point can be better than what a developer would hand-write, not merely faster to obtain.
A Module That Watches the Query Files
The first module — call it the GraphQL toolkit — is an ordinary Nuxt module of the kind Part 6 described. Its setup() runs at build time and during dev-server startup: it finds .graphql files, validates each operation against the schema, generates TypeScript into .nuxt/graphql-toolkit/, and registers the generated functions as auto-imports. In development it also watches those files so a save regenerates the output.
Two properties of this arrangement matter more than the file names.
The generated code lives in .nuxt/, which is build output. It is not committed, it is regenerated from the .graphql files and the schema, and deleting it is always safe. The committed sources are the operations; everything else is derived — which is the same discipline Part 5 argued for, now enforced by where the files live.
And because the module registers auto-imports, consumers never write a path into generated code. If the generator’s output layout changes, no component changes with it.
Choosing the Client and the Cache Key
Here is where a generated entry point beats a hand-written one, because the two decisions it embeds are the two developers get wrong.
Which client. The document application talks to more than one GraphQL endpoint — the composed gateway for application data, and in some configurations the CMS directly for content operations. Which client an operation needs is a property of the operation, so it can be derived from where the file lives rather than passed at every call site: queries/api/… gets the application client, queries/cms/… gets the content client. The developer stops choosing, and the choice stops being inconsistent.
Which cache key. This is the important one. useAsyncData deduplicates and serializes by key, so the key has to include the operation identity and the normalized variables, plus any request context that changes the result — locale, preview mode, and the experiment assignment from Part 11 if it affects the data. A generator can construct that key the same way every time. A developer under time pressure writes 'document' and ships a page that shows document 42’s data on document 43’s URL after client-side navigation.
The honest limits of the generated composable are worth stating so nobody treats it as a guarantee. It does not promise a single fetch: if variables change, the key changes and a new request happens, which is correct. It does not promise payload reuse on hydration unless the server and browser compute the same key from the same state — the consistency requirement from Part 2, which is why request context has to reach the key rather than being read from a global. And it does not decide error semantics for you; it surfaces them in whatever shape the application has standardized.
Reusing an Entry That Was Already Fetched
A specific and common waste: the page queries a collection — a document’s revisions, say — and a child component needs one of them. The options without help are all mildly bad. Refetch the collection in the child and pay for the data twice. Thread the item down through props, coupling the child to wherever it happens to be mounted. Or reach into data.value.document.revisions.items[i], which compiles today and breaks whenever the selection set changes.
The generated entry function is a typed accessor for that case: ask for one revision by its identifier, get it from the data already in the SSR payload if it is there, and fetch it only if it is not.
The condition is the part to read carefully. Reuse is only valid when the already-fetched entry satisfies the shape the child needs. A collection query that selected id and name does not satisfy a child that wants validFrom and description, and a list that contains identifier stubs satisfies nothing at all. So the accessor has to compare the required selection against what was fetched and fall back to a request when the entry is incomplete — otherwise “reuse” means handing the child a partially populated object and letting it discover the gaps at runtime, which is precisely the class of bug Part 5 set out to eliminate.
Translation Keys the Compiler Knows About
The second module addresses the labels. Conventional vue-i18n usage puts the key in a string: t('document.compare.title'). It works, and it has two costs that scale badly. A typo is a runtime surprise that leaks a raw key into the UI, and renaming a key is a find-and-replace across templates with no compiler help. Discovery is worse: finding the right key means reading YAML.
The module generates a TypeScript interface tree mirroring the translation files, and exposes access through a small proxy so that keys are property paths:
// Generated from i18n/locales/en/document.yaml
interface DocumentTranslations {
compare: { title: string; empty: string }
revision: { label: (revision: number) => string }
}
// In a component
t.document.compare.title // → "Compare revisions"
Two things to be clear about. This is one route to typed translations, not the only one: vue-i18n supports typed resources directly, and if that covers your needs it is less machinery to own. What the proxy adds is navigability — typing t. in an editor lists what exists, which changes how it feels to work in an unfamiliar area of the application — and what it costs is a thin runtime indirection plus a generator to maintain. That is a trade, not a free win.
Parameters and Formatters in Translations
Labels with values are where typed keys earn their place. A string like Revision {0} or one annotated with a number formatter is analyzed when the types are generated, and the placeholder becomes a parameter in the generated signature:
t.document.revision.label(7) // ✓ typed as number
t.document.revision.label('7') // ✗ compile error
That catches a real class of mistake — passing the wrong thing, or forgetting a required parameter entirely, which otherwise renders a label with a visible {0} in it.
It is worth being precise about the scope of the check. The compiler verifies the call against the shape derived from one locale’s file. It does not verify that every locale has the key, that translators kept the placeholders, or that the formatter produces the right output for a given locale — a number formatted for one locale and a date formatted for another are separate concerns the generator does not police. Locale coverage and placeholder consistency are their own build check, and worth having: a missing key in a secondary locale is invisible until someone switches language.
Scaffolding a New Module
Both generators assume modules follow the layout from Part 6. A scaffolding generator — plop, in this case, with Handlebars templates in the repository — makes that the default rather than something new developers reconstruct from an existing module:
$ npx plop
? What type of module? Feature
? Module name: revision-compare
? Include server handlers? Yes
Created:
✓ modules/revision-compare/index.ts
✓ modules/revision-compare/runtime/composables/useRevisionCompare.ts
✓ modules/revision-compare/runtime/server/plugin.ts
✓ modules/revision-compare/types.d.ts
✓ modules/revision-compare/README.md
The value is not the keystrokes; it is that the scaffold encodes the decisions — where runtime code lives, that configuration is declared in types.d.ts, that a README exists — so a new module starts consistent instead of becoming consistent during review.
The cost is one worth naming, because scaffolding is easy to add and easy to forget: templates freeze today’s conventions. When the module shape changes, the templates need the same change, or the generator starts producing code that a reviewer will ask to be rewritten. Scaffolding is a maintained artifact, not a one-off gift to the team.
How Generated Output Goes Wrong
Three failure modes account for most of the confusion these modules can cause, and all three are about time rather than logic.
Stale watcher output. In development, a watcher regenerates on save. If a generation run fails — an invalid operation, a schema the gateway cannot serve — what remains on disk is the previous output, which still compiles. The application then behaves according to a query you no longer have. Generation failures need to be loud in the dev server, not a line that scrolls past.
Overlapping runs. Save three files quickly and a naive watcher starts three generation passes writing to the same directory. Serializing runs, or debouncing and regenerating the whole set, avoids output that is a mixture of two passes — the kind of state where the file contents make no sense and the next clean build fixes it mysteriously.
Generated output that is not regenerated in CI. Because .nuxt/ is disposable, a release build must generate before it type-checks, against the schema for that release. This is Part 5’s point restated at the level of entry points: the check is only meaningful against the current contract. A fresh clone with no .nuxt/ directory should produce a working, type-checked build with one documented command — and if it does not, the generators are part of a developer’s local setup rather than part of the build.
One rule covers the everyday version of all three: developers change .graphql files, YAML files, and generator templates. Nobody edits generated output, because the next run silently discards it.
The Workflow End to End
What the two modules buy is best measured by what adding a feature now takes. For the revision-comparison page: write the operation in a .graphql file, add the labels to a YAML file, and use useDocumentDetailsQuery and t.document.compare.* in the component. No imports, no hand-written useAsyncData wrapper, no invented cache key, no key strings, and a type error at the usage if any of it does not line up.
What remains yours is the part no generator can supply: the operation is a real design decision about which fields a page needs, the labels are a real writing decision, and the generated output is only trustworthy when the pipeline regenerates it against the current schema and checks it. The generators removed the transcription, not the thinking.
Which gives the check I would apply to any generator before adopting it: delete its entire output directory and run the documented build command. If what comes back is identical and the build is green, the generator is part of your architecture. If something has to be restored by hand, it is a convenience with a dependency on someone’s laptop.
What’s Next
- Part 8: The Compose Pattern — The comparison page needs a card with a new variant, which turns out to mix styling decisions with rendering behaviour.
- Part 9: Nuxt and a Headless CMS — What happens when editors, not routes, decide what a page contains.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net