(Real patterns from a 500-page admin panel that’s been running Vue 3 + Nuxt 3 in production since Jan 2025)
1. The “One File, One Responsibility” religion
Every single page, layout, or modal lives in exactly one .vue file. No /components, /hooks, /utils, /composables nonsense scattered everywhere.
Example: a full “User Management” page with search, pagination, bulk actions, export, and role filters is 180 lines total — and 160 of them are the template.
We literally banned folders named composables/ and hooks/. If you need something reusable, make a component or a Pinia store. Otherwise keep it local.
2. The “server-first, client-second” data pattern
99 % of our data comes from this exact shape (copy-paste everywhere): <script setup lang=”ts”> const route = useRoute() // Runs on server during SSR, cached on edge, re-runs on client only when stale const { data: users, pending, refresh } = await useAsyncData( `users-${route.query.page}-${JSON.stringify(route.query.filters)}`, () => $fetch(‘/api/users’, { query: route.query }), { server: true, lazy: false } ) // Instant refresh without full page reload async function applyFilters() { await navigateTo({ query: newFilters }) await refresh() // nuxt magically knows what to re-fetch } </script>
No useEffect, no useSWR, no TanStack Query, no invalidateTags dance.
3. The “Form = Pure Server Action” rule
Every form posts directly to a Nuxt server route. No client-side validation library needed. <template> <Form :action=”`/api/organizations/${org.id}/update`” method=”post” @submit=”pending = true”> <input name=”name” :value=”org.name” required /> <button :disabled=”pending”> {{ pending ? ‘Saving…’ : ‘Save’ }} </button> </Form> </template>
On the server (server/api/organizations/[id]/update.ts) we just do:
export default defineEventHandler(async (event) => { const form = await readFormData(event) await db.organization.update({ where: { id: event.context.params.id }, data: form }) return redirect(/organizations/${event.context.params.id}) })
Form state, loading state, redirect after success — all handled by the browser + Nuxt. Zero JS.
4. The “Table = Dumb Component” pattern that ended all debates
We have exactly one <DataTable> component used on 380 pages.
It accepts:
- items.value (ref or shallowRef)
- columns (array with label + key + sortable + formatter)
- loading (boolean)
- pagination (object)
That’s it.
All sorting, filtering, pagination happens via URL query params. The table never touches any API directly.
Result: every table in the app works exactly the same, looks the same, and is keyboard-accessible out of the box.
5. The “Permission = Simple Boolean” trick
No more “useCan(‘user.delete’)” hooks everywhere.
We just add a tiny middleware globally:
// middleware/auth.global.ts export default defineNuxtRouteMiddleware((to) => { const user = useUser() if (to.meta.requires?.includes(‘admin’) && !user.value?.isAdmin) { return navigateTo(‘/unauthorized’) } })
Pages declare it once: <script setup> definePageMeta({ requires: [‘org.member’] }) </script>
Done. No more permission checks in 47 different places.
6. The “Dark Mode = One Line” reality
const colorMode = useColorMode() colorMode.preference = ‘dark’ // or ‘light’ or ‘system’
No context provider, no Redux slice, no useEffect listening to media queries.
7. The “Toast = Global Import” we never regretted
import { useToast } from ‘~/composables/toast’ const toast = useToast()
toast.success(‘User deleted’) toast.error(‘Something went wrong’, { description: error.message })
One file, 40 lines, used in 400+ places. Works on server (queued) and client.
The final scorecard after 12 months
| Thing we used to have in React | Lines of code | Do we miss it? |
|---|---|---|
| 52 custom hooks | ~18 000 | Not even slightly |
| TanStack Query setup | ~4 200 | Nope |
| Redux + 28 slices | ~12 000 | Lol no |
| 380 “use client” directives | 380 | Gone forever |
| Custom form library | ~6 000 | Deleted |
| Total | ~40 000 | Now ~3 800 |
Useful links we actually open every day
- useAsyncData docs (read this 10 times): https://nuxt.com/docs/api/composables/use-async-data
- Server routes (the real MVP): https://nuxt.com/docs/guide/directory-structure/server
- <script setup> + TypeScript magic: https://vuejs.org/api/sfc-script-setup.html
- Nuxt middleware (permissions, auth, etc.): https://nuxt.com/docs/guide/directory-structure/middleware
Final thought
Vue 3 + <script setup> + Nuxt 3 didn’t win the popularity contest.
It won the “I need to ship 500 boring enterprise pages before the quarter ends and I don’t want to hate my life” contest.
And honestly? That’s the only contest that pays the bills.
We’re still hiring. Must be okay with being stupidly productive and never writing another custom hook again.
Next post already written: “How we accidentally made our Vue app fully type-safe in templates and killed 92 % of runtime errors”
Say go and I’ll drop it tomorrow.