AGilbertDev's frontend conventions for Nuxt/Vue projects — component and composable choices, solution priority, icons, and page performance. Use when building UI, components, or pages in a personal Nuxt project, or when a page is slow and you need to find what is actually costing the time. Pairs with nuxt-conventions:styling for Tailwind and theming.
Install
npx skillscat add agilbertdev/claude-plugins/frontend Install via the SkillsCat registry.
Frontend conventions (Nuxt / Vue)
Solution priority
Reach for solutions in this order, and stop at the first that fits:
- Nuxt UI components and composables
- Nuxt core features
- Custom Vue
- Tailwind utilities for styling gaps
Look up the official docs and explain the reasoning rather than guessing. When naming a component or API, name it exactly (for example UFormField with a UInput inside) so it is easy to look up.
Components
- Prefer Nuxt UI primitives (
UButton,UCard,UForm,UModal,UTable,UInput, and so on) before building custom. - Vue 3 Composition API with
<script setup>. Keep components small and composable; pull shared logic into composables.
Creating a row the user names
A form that creates a row carrying a user-supplied name collects that name in every supported locale at once, so the screen shows a French field and an English field side by side rather than one field and a promise to translate later. French leads, since the app is French first.
The names go to the translations table described in nuxt-conventions:backend, never into the locale JSON files. Interface copy is i18n and ships with the app. A name the user invents at runtime is data and lives in the database. Do not let a component read one from the other.
Data mutations and cache invalidation
Always invalidate the client cache after a mutation. Any write that changes server state (a $fetch POST, PATCH, or DELETE) must be followed by refreshing whatever client-side cache reads that state, so the UI reflects the change without a full page reload.
- Data loaded with
useFetchoruseAsyncData: call the returnedrefresh()after the write, orrefreshNuxtData(key)for a shared key. - State that lives in the
nuxt-auth-utilssession (anything read offuser): callfetch()fromuseUserSession()after the write souserre-reads. - State derived from the session once does not re-derive when
userre-reads. AuseStateseeded fromuser(for example theuseThemelight and dark ids) and the active i18n locale keep their first value, so re-apply them by hand in the same success handler by setting theuseThemestate and callingsetLocale. Refreshing the session alone leaves these looking reverted. - An optimistic local update is fine for responsiveness, but the authoritative refetch still has to run so the cache and the server agree.
Never rely on the next navigation or reload to pick up a change. A stale client cache after a mutation is a bug.
Server state with TanStack Query
Use TanStack Query (@tanstack/vue-query) for reading and writing server state, layered on Nuxt's data fetching. Register it once in a Nuxt plugin with SSR hydration, dehydrating on the app:rendered hook and hydrating on the client.
- Query keys live in one factory file,
app/queries/keys.ts, exported as aqueryKeysobject with a function per key. A key is never hand-typed at a call site, so the keys a mutation invalidates always match the queries that produced them. - Query and mutation composables live in
app/composables/, auto-imported, nameduseXxxQueryanduseXxxMutation. A page reads and writes server state through these composables rather than a bare$fetch. - Every mutation invalidates the affected query keys in
onSuccesswithqueryClient.invalidateQueries. Session-backed state is not in the query cache, so it is still refreshed throughuseUserSession().fetch()in the sameonSuccess, following the mutations section above.
Loading state on submit
Every form submit shows a loading state on its submit control while the write is in flight. Bind the submit UButton's :loading to the mutation's isPending, or to the local in-flight flag when the write is not a TanStack mutation, and keep the control disabled until it settles. A slow write is then never mistaken for a dead button and cannot be double-submitted.
Icons
- Phosphor is the default set, via the Nuxt UI icon prop (
i-ph-*). Match the icon weight to the text it sits with: use the-boldvariants next to bold or large text so the glyph does not look thin, and scale the icon up as the text scales. Pick one weight family per project and stay consistent. - Simple Icons for brand and logo marks only (
i-simple-icons-*).
Page composition
- Build a long landing or portfolio page as one route that scrolls through sections, each its own component under
components/home/(hero, about, experience, and so on), assembled in the page. Keep the page file a thin list of those sections. - A small reusable
SectionHeadercomponent (a monotext-primarykicker plus the sectionh2) keeps headers consistent. Exactly oneh1(the hero), oneh2per section, in order. - For in-page anchor nav, keep the section ids in one composable (for example
useSectionId) so the nav links and each<section :id>always agree. On a bilingual site make the ids locale-aware there (#a-propos/#about), and translate the current hash when toggling locale so the toggle stays on the same section.
Performance
Find the bottleneck before changing anything, in this order.
- TTFB. Is the page cached at the CDN, or does every visit run a function? On Vercel,
curl -sI <url> | grep -i x-vercel-cache.MISSevery time means nothing is cached. Measure a request after the site has been idle, because on a quiet site the cold start is the normal arrival. - Render-blocking CSS. The stylesheet in the head holds up the first paint.
- The JS bundle. Run
nuxi analyzerather than guessing what is in it. Module scripts are deferred, so the bundle blocks interactivity rather than paint. - Fonts. Count the files and weights actually rendered, not the ones configured.
A skeleton only helps for something that arrives on a later request than the markup. Server-rendered text ships in the same response as any skeleton standing in for it, so the skeleton waits exactly as long and then shows a grey box first. An image is a separate request, so @nuxt/image's placeholder is worth having.
Prerendering and i18n
routeRules: { '/': { prerender: true } } puts a page on the CDN and removes the function, which on a quiet site is the biggest win available.
It does not combine with browser-language detection. A prerendered page gets no request, so detectBrowserLanguage has nothing to read and silently never fires. ISR does not rescue it either, because i18n writes its cookie on every render and Vercel will not cache a response carrying set-cookie. On a prerendered bilingual site, set detectBrowserLanguage: false and let the language toggle do the switching. hreflang and the canonicals still send search engines to the right URL, so only the first-visit redirect is lost.
Scroll reveal
- Reveal sections on scroll with a small client plugin: add a
jsclass to<html>, hide[data-reveal]elements only when that class is present (so a no-JS render still shows everything), then add anis-inclass through anIntersectionObserveron mount and after each navigation. Stagger children with a--reveal-icustom property. Gate the whole effect behindprefers-reduced-motion.
Pages
useSeoMeta()on every page component, withtitleanddescriptionat minimum. Theseo-checklistskill has the full list for public pages.- Keep components small. A second
<script setup>concern means a split.
i18n
useI18n()for every user-facing string. Never hardcode copy in a template.- Add the
frandenkeys at the same time, in the matching locale files.
Boundaries
- Never write server route or database code in a
.vuefile. - Be a view with as little brain as possible. Draw what the server hands you. A derived value arrives resolved. When the response lacks the data you need, the fix is a backend change, not a computation here.
- Only presentation logic belongs in a component. Focus, open and closed state, hover and transition, a visual breakpoint, and display formatting of resolved data.
- Never let a component be the only thing enforcing a rule. Mirror it on the server.
- When both sides need the same pure rule, import it from
shared/. Never copy it.