Two Toolkit Modules — Generating Composables for Queries and Typed Translations
Photo by Aleksandra Żmuda on Pexels
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 a CMS 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 a CMS Entry That Was Already Fetched
The third generated function in the diagram, usePageSectionEntry, needs a word of introduction, because Part 5 deliberately did not mention it. Part 5 described what any operation gets from generation: the types, the typed document, the reactive composable, the imperative client. The entry accessor is not in that set. It is generated only for operations against the headless CMS — hence the name in the diagram, which derives from the CMS operation rather than the application one — and it exists only because of how content is modelled there.
In the CMS, every addressable piece of content — a page, a section, a teaser, an image — is an entry with its own identifier. A page entry does not embed its sections; it references them. So the page query can either follow those references to some fixed depth, or select each referenced entry as a stub: its identifier and its type, nothing more. Part 9 explains why this application takes the second route and what it costs. What matters here is the consequence for the call site: a child component is handed something that may be a stub, may be a fully populated entry from the page’s payload, and in either case is identified by an id.
Take a concrete case. The page query fetched the page and, for each of its sections, an identifier and a title. A Section component now renders one of those sections and needs more than a title: the section’s layout and its own list of child references. Whether the payload already contains those fields depends on what the page query selected, which differs from page to page and changes over time — so the component cannot assume either way.
A developer writing this by hand has three ways to resolve that, and each one trades a different thing away:
- Always fetch in the child. Simple and always correct, but every section issues a request even when the page already fetched everything it needed.
- Pass the data down as props. No extra request, but now the component only works where a parent happens to supply that exact shape, so it cannot be dropped into another page or nested one level deeper.
- Read from the page’s result directly, as in
data.value.page.sections.items[i]. It compiles and it is free, but the component is now reading the page query’s selection set. Remove a field from that query and the child silently loses it; the type it was relying on came from a query it does not own.
The generated entry function does the checking instead. The component asks for one entry by its identifier and states, through the operation it was generated from, which fields it needs. If the payload already holds a version of that entry with those fields, it is returned as it is; if it holds only a stub or a partial version, the accessor fetches. The component keeps the first option’s correctness without the request, and keeps working wherever it is mounted because it depends on its own query rather than its parent’s.
That check is the whole difficulty, and skipping it is tempting because matching identifiers look like a match. They are not: the page query’s section and the child’s section are the same entry at different levels of detail. An accessor that returns the payload’s copy whenever the id matches hands the child an object missing half its fields, and the gaps show up at runtime as empty output rather than as an error — precisely the class of bug Part 5 set out to eliminate.
This is also why the accessor is generated per CMS operation rather than written once by hand: the required selection it has to check is the one in that operation’s .graphql file, and it has to stay correct when the file changes.
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. The runtime piece is a JavaScript Proxy, an object that intercepts property access instead of holding the properties: reading t.document.compare returns another proxy that remembers the path so far, and calling the last segment — t.document.compare.title() — hands "document.compare.title" to vue-i18n and returns the translated string. The call is what performs the lookup; the property path alone is just an accumulated key, so every leaf in the generated interface is a function:
// 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"
Nothing is duplicated at runtime — the translations still live in the i18n resources — but the path is now made of identifiers the compiler checks against the generated interface.
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: Nuxt in Production