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 worked out what a module owns and when its code runs. This article joins the two together: two modules that generate the entry points developers actually use, a composable per query and a typed object for translations, so that all the repetitive wiring between a contract and a component gets produced by the build instead of being typed out again for every feature.
The examples carry on with 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 gives us types and a typed document for each operation. That’s 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 everything 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 doesn’t go and refetch. And then the new page needs three labels, which means opening a YAML file, inventing dotted key strings, and hoping nobody types documnet.
Every one of those steps is mechanical, and every one of them is a place where two developers will happily make two different choices. That’s what these two modules generate: not the contract, but the ergonomics around it. And the question driving the whole thing is whether the generated entry point can be better than what a developer would write by hand, not merely quicker to get hold of.
A Module That Watches the Query Files
The first module, which I’ll call the GraphQL toolkit, is a perfectly ordinary Nuxt module of the kind Part 6 described. Its setup() runs at build time and during dev-server startup: it finds the .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 saving one regenerates the output.
Two properties of this arrangement matter rather more than the file names do.
The generated code lives in .nuxt/, which is build output. It isn’t committed, it gets regenerated from the .graphql files and the schema, and deleting it is always safe. The committed sources are the operations, and everything else is derived from them. That’s the same discipline Part 5 argued for, except now it’s enforced by where the files live.
And because the module registers auto-imports, consumers never write a path into generated code at all. If the generator’s output layout changes, not a single component changes with it.
Choosing the Client and the Cache Key
And here’s where a generated entry point genuinely beats a hand-written one, because the two decisions it embeds happen to be the two that developers get wrong.
Which client. Our 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 itself, so it can be derived from where the file lives rather than passed in 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 that affects the data. A generator will construct that key the same way every single time. A developer under time pressure writes 'document' and ships a page that shows document 42’s data on document 43’s URL after a client-side navigation.
The honest limits of the generated composable are worth spelling out, so nobody mistakes it for a guarantee. It doesn’t promise a single fetch: if the variables change then the key changes and a new request happens, which is correct behaviour. It doesn’t promise payload reuse on hydration unless the server and the browser compute the same key from the same state, which is the consistency requirement from Part 2 and the reason request context has to reach the key instead of being read out of a global. And it doesn’t decide error semantics for you; it just surfaces them in whatever shape the application has standardized on.
Reusing a CMS Entry That Was Already Fetched
The third generated function in that diagram, usePageSectionEntry, needs a word of introduction, because Part 5 deliberately kept quiet about it. Part 5 described what any operation gets out of generation: the types, the typed document, the reactive composable, the imperative client. The entry accessor isn’t in that set. It only gets generated for operations against the headless CMS, hence the name in the diagram deriving from the CMS operation rather than the application one, and it only exists because of how content is modelled over there.
In the CMS, every addressable piece of content, whether that’s a page, a section, a teaser or an image, is an entry with its own identifier. A page entry doesn’t 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, meaning its identifier and its type and nothing else. Part 9 explains why this application takes the second route and what that costs. What matters here is the consequence at the call site: a child component gets handed something which may be a stub, may be a fully populated entry from the page’s payload, and is identified by an id either way.
Let’s take a concrete case. The page query fetched the page and, for each of its sections, an identifier and a title. Now a Section component renders one of those sections and needs rather more than a title: the section’s layout and its own list of child references. Whether the payload already holds those fields depends on what the page query selected, which differs from page to page and changes over time. So the component can’t safely assume either way.
A developer writing this by hand has three ways out, and each one trades away something different:
- Always fetch in the child. Simple, and always correct, but then 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 somewhere a parent happens to supply that exact shape, so you can’t drop it into another page or nest it one level deeper.
- Read from the page’s result directly, as in
data.value.page.sections.items[i]. It compiles and it’s 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, because the type it was relying on came from a query it doesn’t own.
The generated entry function does the checking for you 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 comes back as it is. If the payload holds only a stub or a partial version, the accessor fetches. So the component keeps the first option’s correctness without paying for the request, and it keeps working wherever you mount it, because it depends on its own query rather than on its parent’s.
That check is the entire difficulty, and skipping it is tempting because matching identifiers look for all the world like a match. They aren’t: the page query’s section and the child’s section are the same entry at different levels of detail. An accessor that hands back the payload’s copy whenever the id matches gives the child an object missing half its fields, and the gaps turn up at run time as empty output rather than as an error. Which is precisely the class of bug Part 5 set out to get rid of.
This is also why the accessor gets generated per CMS operation instead of 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 that file changes.
Translation Keys the Compiler Knows About
The second module takes on the labels. Conventional vue-i18n usage puts the key in a string: t('document.compare.title'). That works, and it has two costs that scale badly. A typo is a runtime surprise which leaks a raw key into the UI, and renaming a key is a find-and-replace across your templates with no help whatsoever from the compiler. Discovery is worse still: finding the right key means going and reading YAML.
So the module generates a TypeScript interface tree mirroring the translation files, and exposes access through a small proxy, so that keys become property paths. The runtime piece is a JavaScript Proxy, an object that intercepts property access instead of actually holding the properties. Reading t.document.compare returns another proxy which remembers the path so far, and calling the last segment, so 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 on its own is just an accumulated key. Which is why 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 gets duplicated at run time, since the translations still live in the i18n resources, but the path is now made up of identifiers that the compiler checks against the generated interface.
Two things I want to be clear about. This is one route to typed translations and not the only one: vue-i18n supports typed resources directly, and if that covers your needs then it’s considerably less machinery to own. What the proxy adds is navigability, because typing t. in an editor lists what actually exists, and that genuinely changes how it feels to work in an unfamiliar corner of the application. What it costs you is a thin runtime indirection plus a generator to maintain. So it’s a trade, not a free win.
Parameters and Formatters in Translations
Labels with values in them are where typed keys really earn their place. A string like Revision {0}, or one annotated with a number formatter, gets analyzed when the types are generated, and the placeholder turns into a parameter in the generated signature:
t.document.revision.label(7) // ✓ typed as number
t.document.revision.label('7') // ✗ compile error
That catches a genuinely real class of mistake: passing the wrong thing, or forgetting a required parameter altogether, which otherwise renders a label with a nice visible {0} sitting in it.
But let’s be precise about the scope of that check. The compiler verifies the call against the shape derived from one locale’s file. It doesn’t verify that every locale has the key, that the 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 which the generator doesn’t police at all. Locale coverage and placeholder consistency want their own build check, and that’s worth having, because a missing key in a secondary locale stays invisible until somebody switches language.
Scaffolding a New Module
Both generators assume that modules follow the layout from Part 6. A scaffolding generator (plop, in this case, with Handlebars templates kept in the repository) makes that layout the default instead of something new developers have to reconstruct by squinting at 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 here isn’t the saved keystrokes. It’s that the scaffold encodes the decisions, so where runtime code lives, that configuration is declared in types.d.ts, that a README exists at all. A new module therefore starts out consistent instead of becoming consistent somewhere during review.
And the cost is worth naming, because scaffolding is easy to add and even easier to forget about: templates freeze today’s conventions. When the module shape changes, the templates need exactly the same change, or the generator starts cheerfully producing code that a reviewer will ask somebody to rewrite. 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 are capable of causing, and all three of them are about time rather than about logic.
Stale watcher output. In development, a watcher regenerates on save. If a generation run fails, because of an invalid operation or a schema the gateway can’t serve, then what stays sitting on disk is the previous output, which still compiles perfectly well. The application then behaves according to a query you don’t have any more. So generation failures need to be loud in the dev server rather than one line that scrolls past.
Overlapping runs. Save three files in quick succession and a naive watcher kicks off three generation passes all writing into the same directory. Serializing the runs, or debouncing and regenerating the whole set, avoids output that’s a mixture of two passes. That’s the kind of state where the file contents make no sense at all and then the next clean build fixes it mysteriously.
Generated output that never gets regenerated in CI. Because .nuxt/ is disposable, a release build has to generate before it type-checks, and against the schema for that release. This is Part 5’s point again, restated at the level of entry points: the check only means anything against the current contract. A fresh clone with no .nuxt/ directory should give you a working, type-checked build from one documented command. If it doesn’t, then your generators are part of somebody’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 will silently throw it away.
The Workflow End to End
The best way to measure what these two modules buy you is to look at 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 then 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 right at the usage if any of it fails to line up.
What stays 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 took away the transcription, not the thinking.
Which gives me the check I’d 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, then the generator is part of your architecture. If anything has to be restored by hand, then it’s a convenience with a dependency on somebody’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