Blueprint for a new project
Read when: you start a new Nuxt UI app, add a folder or file and need to know where it goes, or name
something.
Owns: the folder tree for app/, server/, shared/; the day-one file list; the layouts and error page;
the house components and composables with their signatures; naming rules.
TL;DR
- Nuxt 4 layout:
app/(the page),server/(Nitro),shared/(types and helpers both use). - Day one: copy the three files in
assets/, then write the files in "Day one" below in that order. - Every screen is built from the house components here plus Nuxt UI; patterns in components.md.
- Names say what a thing is:
InvoicesPage,InvoiceModal,useDeleteInvoice,invoices.post.ts.
Folder tree
app/
app.vue UApp, html lang/dir, NuxtLayout (i18n-rtl.md#two-settings-both-needed)
app.config.ts ui colours and component defaults (assets/app.config.ts)
error.vue full-page error, outside every layout
assets/css/main.css the theme (assets/main.css)
layouts/
dashboard.vue sidebar + header tools + main panel
auth.vue centred card for sign-in and first setup
pages/ one file per URL; a page picks its layout with definePageMeta
components/ auto-imported, flat, PascalCase (see naming)
composables/ useX(): state and actions that need Vue context (toast, overlay, route)
utils/ plain functions and module-level refs: format, labels, locale
plugins/ i18n.ts (hands the dictionaries to shared/utils/i18n.ts)
server/
api/ /api/* handlers, one file per route and method
invoices.get.ts invoices.post.ts invoices/[id].post.ts invoices/[id].delete.ts
auth/login.post.ts auth/logout.post.ts
routes/ files served at the root: sitemap.xml.ts, robots.txt.ts
middleware/ runs on every request in name order: 1.guard.ts, 2.auth.ts
plugins/ Nitro start-up code (restart dev after creating the folder)
utils/ auto-imported in server code: handler, locale, db, auth, one file per domain
shared/
types/ types the page and server both read (no runtime values)
utils/ i18n.ts and pure rules both sides run (validation, formatting of codes)
locales/ ar.ts, en.ts
public/ logo.svg, favicon.svg, copied as is
tests/ bun test or vitest, outside app/ and server/
app/components,app/composables,app/utilsandshared/utilsare auto-imported in the page;server/utilsandshared/utilsin the server. Server code never seesapp/utils.shared/imports runtime data from a relative path only as types; see gotchas.md.
Day one
| # | File | Source |
|---|---|---|
| 1 | package.json, .nvmrc, nuxt.config.ts |
setup.md, ../assets/nuxt.config.ts |
| 2 | app/assets/css/main.css, app/app.config.ts |
../assets/main.css, ../assets/app.config.ts |
| 3 | shared/utils/i18n.ts, shared/locales/{ar,en}.ts, app/utils/locale.ts, app/plugins/i18n.ts, server/utils/locale.ts |
i18n-rtl.md |
| 4 | app/app.vue |
i18n-rtl.md |
| 5 | app/layouts/dashboard.vue, app/layouts/auth.vue, app/error.vue |
below |
| 6 | server/utils/handler.ts, server/middleware/1.guard.ts, app/composables/useApi.ts |
setup.md |
| 7 | the house components and composables | below |
| 8 | public/logo.svg, public/favicon.svg |
your own |
Layouts
app/layouts/dashboard.vue
The shell: sidebar w-64 from lg, a header with a tool pill, and the page on a rounded panel over the grey
page. Under lg a USelect replaces the sidebar.
<script setup lang="ts">
const route = useRoute()
const nav = computed(() => [
{ label: t('nav.overview'), icon: 'i-lucide-layout-dashboard', to: '/' },
{ label: t('nav.invoices'), icon: 'i-lucide-receipt', to: '/invoices' },
{ label: t('nav.settings'), icon: 'i-lucide-settings', to: '/settings' },
])
const isCurrent = (to: string) => (to === '/' ? route.path === '/' : route.path.startsWith(to))
const current = computed(() => nav.value.find(item => isCurrent(item.to))?.to)
</script>
<template>
<div class="min-h-screen bg-muted lg:flex">
<aside class="hidden w-64 shrink-0 flex-col gap-6 px-3 py-4 lg:sticky lg:top-0 lg:flex lg:h-screen lg:overflow-y-auto">
<AppBrand class="px-2" />
<nav class="space-y-1">
<NuxtLink
v-for="item in nav" :key="item.to" :to="item.to"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="isCurrent(item.to) ? 'bg-elevated text-highlighted' : 'text-toned hover:bg-elevated/70'"
:aria-current="isCurrent(item.to) ? 'page' : undefined"
>
<UIcon :name="item.icon" class="size-4.5" /> {{ item.label }}
</NuxtLink>
</nav>
<USeparator />
<!-- a secondary list: customers, projects, ... -->
</aside>
<div class="flex min-w-0 flex-1 flex-col gap-3 p-3 lg:ps-0">
<header class="flex flex-wrap items-center justify-between gap-3 px-1 lg:min-h-10">
<div class="flex min-w-0 items-center gap-2 lg:hidden">
<AppBrand compact />
<USelect
:model-value="current" :items="nav.map(item => ({ label: item.label, value: item.to }))"
class="w-52 max-w-full" :aria-label="t('nav.picker')" @update:model-value="to => navigateTo(String(to))"
/>
</div>
<span class="hidden lg:block" />
<HeaderTools />
</header>
<main class="flex-1 space-y-6 rounded-2xl border border-default bg-default p-4 sm:p-6 lg:p-8">
<slot />
</main>
<AppFooter />
</div>
</div>
</template>
min-w-0on the content column, or tables and long IDs push it wider instead of shrinking.- A page opts in with
definePageMeta({ layout: 'dashboard' }), or name the filedefault.vue. - Nuxt UI's
UDashboardGroup/UDashboardSidebar/UDashboardPanelis the alternative when you need a resizable or collapsible sidebar: components.md.
app/layouts/auth.vue
<template>
<div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-muted p-4">
<div class="w-full max-w-md space-y-6 rounded-2xl border border-default bg-default p-6 sm:p-8">
<div class="flex items-start justify-between gap-3">
<AppBrand />
<LanguageButton />
</div>
<slot />
</div>
<AppFooter />
</div>
</template>
app/error.vue
Rendered instead of the whole app, so it repeats UApp and reads the same theme.
<script setup lang="ts">
import type { NuxtError } from '#app'
import { ar, en } from '@nuxt/ui/locale'
const props = defineProps<{ error: NuxtError }>()
const notFound = computed(() => props.error.statusCode === 404)
useHead({ htmlAttrs: { lang: () => locale.value, dir: () => (isRtl.value ? 'rtl' : 'ltr') } })
</script>
<template>
<UApp :locale="locale === 'ar' ? ar : en">
<div class="grid min-h-screen place-items-center bg-muted p-6">
<div class="grid max-w-md place-items-center gap-3 text-center">
<span class="grid size-14 place-items-center rounded-full bg-elevated text-muted">
<UIcon :name="notFound ? 'i-lucide-file-question' : 'i-lucide-circle-alert'" class="size-6" />
</span>
<p class="font-mono text-sm text-dimmed"><bdi dir="ltr">{{ error.statusCode }}</bdi></p>
<h1 class="text-xl font-semibold text-highlighted">{{ notFound ? t('errors.notFound') : t('errors.title') }}</h1>
<p v-if="!notFound && error.message" class="text-sm text-muted">{{ error.message }}</p>
<UButton :label="t('errors.home')" icon="i-lucide-house" @click="clearError({ redirect: '/' })" />
</div>
</div>
</UApp>
</template>
throw createError({ statusCode: 404 }) in a page reaches it; a client error is non-fatal unless
fatal: true (checked 2026-11-03, nuxt.com/docs/4.x/getting-started/error-handling). A boot failure in a
single-screen app can instead show a centred UAlert color="error" variant="subtle" from app.vue.
House components
| Component | Props / slots | Use |
|---|---|---|
AppBrand |
compact?: boolean |
logo size-9 rounded-[10px] + app name font-semibold text-highlighted; compact hides the name |
AppFooter |
— | a rounded-full border bg-default text-xs text-muted pill: author link, divider, v{{ version }} in font-mono |
HeaderTools |
— | the header pill: refresh, language, colour mode, sign out (components.md) |
LanguageButton |
— | ghost size="sm" button labelled with the language you switch to (EN / AR), font-mono font-semibold |
FieldLabel |
label: string, hint: string |
a form label with the hint behind an info icon |
StatTile |
title, icon, hint?, loading?; default slot = value |
a number card with its own skeleton |
EmptyState |
icon, title; default slot = buttons |
round icon, one line, next actions |
ConfirmModal |
title, description?, confirmLabel?, danger?; emits close: [boolean] |
opened only through useConfirm |
<!-- FieldLabel.vue -->
<script setup lang="ts">
defineProps<{ label: string, hint: string }>()
</script>
<template>
<span class="inline-flex items-center gap-1.5">
{{ label }}
<UTooltip :text="hint"><UIcon name="i-lucide-info" class="size-3.5 text-dimmed" :aria-label="hint" /></UTooltip>
</span>
</template>
<!-- StatTile.vue -->
<script setup lang="ts">
defineProps<{ title: string, icon: string, hint?: string, loading?: boolean }>()
</script>
<template>
<div class="flex items-start justify-between gap-3 rounded-xl border border-default bg-default p-5">
<div class="min-w-0 flex-1">
<p class="flex items-center gap-1.5 text-sm text-toned">
{{ title }}
<UTooltip v-if="hint" :text="hint" :content="{ side: 'top' }"><UIcon name="i-lucide-info" class="size-3.5 text-dimmed" /></UTooltip>
</p>
<div v-if="loading" class="mt-3 space-y-2"><USkeleton class="h-7 w-28" /><USkeleton class="h-3.5 w-36" /></div>
<div v-else class="mt-2"><slot /></div>
</div>
<div class="grid size-10 shrink-0 place-items-center rounded-lg border border-default bg-muted text-toned">
<UIcon :name="icon" class="size-5" />
</div>
</div>
</template>
Value in the slot: <p class="text-2xl font-semibold text-highlighted tabular-nums">, then a
text-xs text-muted line. Tiles sit in grid gap-4 sm:grid-cols-2 lg:grid-cols-4.
<!-- EmptyState.vue -->
<script setup lang="ts">
defineProps<{ icon: string, title: string }>()
</script>
<template>
<div class="grid place-items-center gap-3 py-20 text-center">
<span class="grid size-14 place-items-center rounded-full bg-elevated text-muted"><UIcon :name="icon" class="size-6" /></span>
<p class="font-semibold text-highlighted">{{ title }}</p>
<div class="flex flex-wrap justify-center gap-2"><slot /></div>
</div>
</template>
<!-- ConfirmModal.vue -->
<script setup lang="ts">
defineProps<{ title: string, description?: string, confirmLabel?: string, danger?: boolean }>()
const emit = defineEmits<{ close: [value: boolean] }>()
</script>
<template>
<UModal :title :description :close="false" :dismissible="false" :ui="{ footer: 'justify-end' }">
<template #footer>
<UButton :label="t('common.cancel')" color="neutral" variant="outline" @click="emit('close', false)" />
<UButton
:label="confirmLabel ?? t('common.continue')" :icon="danger ? 'i-lucide-trash-2' : undefined"
:color="danger ? 'error' : 'primary'" @click="emit('close', true)"
/>
</template>
</UModal>
</template>
House composables
| Composable | Signature | Does |
|---|---|---|
useApi |
() => <T>(path, { method?, body?, query? }) => Promise<T> |
the only way the page calls /api: headers, locale, 401, readable Error (setup.md) |
useSession |
() => { info, status, loadApp, signOut }, status: 'loading' | 'setup' | 'login' | 'ready' |
who is signed in; app.vue or a route middleware picks the screen from status |
useConfirm |
() => (props: ConfirmProps) => Promise<boolean> |
opens ConfirmModal through useOverlay |
useCopy |
() => (text: string) => Promise<void> |
clipboard write plus its own toast |
use<Verb><Thing> |
() => (item) => Promise<boolean> |
confirm → write → toast → reload, e.g. useDeleteInvoice |
// composables/useConfirm.ts
import { ConfirmModal } from '#components'
export function useConfirm() {
const overlay = useOverlay()
return (props: { title: string, description?: string, confirmLabel?: string, danger?: boolean }): Promise<boolean> =>
overlay.create(ConfirmModal, { destroyOnClose: true, props }).open().then(Boolean)
}
// composables/useCopy.ts
export function useCopy() {
const toast = useToast()
return async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text)
toast.add({ title: t('common.copied'), color: 'success', icon: 'i-lucide-check' })
}
catch {
toast.add({ title: t('common.copyFailed'), color: 'error' })
}
}
}
// composables/useDeleteInvoice.ts
export function useDeleteInvoice() {
const api = useApi()
const confirm = useConfirm()
const toast = useToast()
return async (invoice: Invoice): Promise<boolean> => {
if (!(await confirm({ title: t('invoices.deleteTitle', { number: invoice.number }), confirmLabel: t('invoices.delete'), danger: true }))) {
return false
}
try {
await api(`/api/invoices/${invoice.id}`, { method: 'DELETE' })
}
catch (error) {
toast.add({ title: t('invoices.deleteFailed'), description: (error as Error).message, color: 'error' })
return false
}
toast.add({ title: t('invoices.deleted'), color: 'success', icon: 'i-lucide-check' })
return true
}
}
Every write that cannot be undone goes through one of these, so none skips the confirmation.
Naming
| Thing | Rule | Example |
|---|---|---|
| Component | PascalCase, flat in components/, noun first |
InvoicesPage, InvoiceForm |
| A whole screen | <Thing>Page |
SettingsPage |
| A tab's body | <Thing>Panel |
ReportsPanel |
| Overlays | <Thing>Modal, <Thing>Slideover |
InvoiceModal |
| Shell pieces | App<Part> |
AppBrand, AppFooter |
| Composable | use<Thing> for state, use<Verb><Thing> for an action |
useSession, useDeleteInvoice |
| Utils file | camelCase noun, plural for a set | format.ts, labels.ts, locale.ts |
| Status helper | <thing>Of(value) → { label, tone, icon } |
statusOf |
| API route | <resource>.<method>.ts, [id] for one item, verbs as sub-paths |
invoices/[id]/send.post.ts |
| Middleware | numbered prefix for order | 1.guard.ts |
| Dictionary key | area.camelCaseKey |
invoices.deleteTitle |
| Storage key | <app>:<name> |
my-app:locale |
| CSS token | --<area>-<name> in main.css |
--chart-bar |
| Icon | i-lucide-<name> |
i-lucide-receipt |
Never name a variable t: it hides the auto-imported t().