Template Nuxt 4 que serve de ponto de partida para qualquer projeto: dashboard atrás de login, site com SEO, SaaS full-stack ou frontend puro contra API externa. A base é agnóstica (não impõe backend, banco nem auth — deixa os pontos de encaixe prontos), enxuta (na dúvida, fica de fora com receita documentada) e copy-and-own na UI (o visual é nosso; bibliotecas estilizadas não entram como dependência). A especificação completa está em SPEC.md.
- Node 22.19+ (ou 24.11+ — o CI usa Node 24)
- pnpm 11 — a versão exata está pinada em
packageManagernopackage.json
pnpm install # instala dependências e roda nuxt prepare
pnpm dev # servidor de desenvolvimento em http://localhost:3000
pnpm build # build de produção (.output/)
pnpm preview # serve o build localmente
pnpm lint # ESLint (inclui formatação — não há Prettier)
pnpm lint:fix # ESLint com autofix
pnpm typecheck # vue-tsc via nuxt typecheck
pnpm test# Vitest (uma execução)
pnpm test:watch # Vitest em modo watchapp/ # código da aplicação (srcDir do Nuxt 4)
assets/css/main.css # design tokens (Tailwind 4 CSS-first) — identidade visual vive aqui
components/ui/ # kit próprio: Button, Input, Select, Modal, Card, Badge, Table, Toaster, Tooltip
composables/ # useApi/useApiData (porta única para a API), useToast
layouts/default.vue # header + nav + toggle de tema + <UiToaster />
middleware/auth.ts # esqueleto do middleware de rota (auth é ponto de encaixe)
pages/ # index, login (placeholder), components (vitrine do kit)
stores/app.ts # store-referência Pinia (setup store)
error.vue # página de erro global + 404
server/
api/health.get.ts # rota-referência do Nitro (GET /api/health)
tests/nuxt/ # specs aqui entram no nuxt typecheck
components/ # referência (mountSuspended) + smoke de regressão do kit
composables/ # teste-referência de composable (registerEndpoint)
conventions/ # teste-inventário: só tokens semânticos
.claude/skills/ # skills de IA (preline-mcp, novo-componente-ui, derivar-projeto, ci-verde)
.env.example # espelho documentado do runtimeConfig
nuxt.config.ts # módulos, css, runtimeConfig
vitest.config.ts # ambiente nuxt global + happy-dom
CLAUDE.md # convenções para sessões de IA
docs/SPEC.md # especificação da base
TL;DR — do zero ao codando:
gh repo create meu-projeto --template DevJanderson/nuxt-base --private --clone
cd meu-projeto
pnpm install # deps + git hooks (lefthook) sozinho
claude # e dentro da sessão: /derivar-projeto meu-projetoVirar um projeto novo = renomear + editar tokens + limpar exemplos (docs/SPEC.md §11). Três caminhos, em ordem de preferência:
- "Use this template" + skill (recomendado) — crie o repositório pelo botão do GitHub (ou
gh repo create <nome> --template DevJanderson/nuxt-base --clone) e, no clone, invoque a skill/derivar-projeto <nome>no Claude Code: ela executa o roteiro completo e valida os gates. - "Use this template" + passos manuais — mesmo início; siga os passos abaixo pulando o item 1 (o histórico já nasce limpo e os hooks se instalam no
pnpm install). - Cópia manual — copie a pasta e rode
rm -rf .git && git init; zerar o.gitapaga os git hooks, então rode tambémpnpm exec lefthook install.
Os passos (a fonte canônica, sempre atualizada, é a skill em .claude/skills/derivar-projeto/):
- Histórico e hooks — só no caminho 3, acima.
- Renomeie em dois lugares:
package.json→ campo"name";server/api/health.get.ts→ camposervice(o healthcheck reporta o nome do serviço). - Tokens em
app/assets/css/main.css, apenas os blocos:roote.dark(e, se quiser,--font-sans/--radius-*) — ver Tema. Sem identidade definida ainda? Mantenha o padrão e siga: trocar depois é editar só esses dois blocos. - Renderização: site/SEO mantém o SSR padrão; dashboard atrás de login →
ssr: false; misto →routeRules— ver SSR ou SPA. - Auth, quatro ramos: receita 1 ou 2; sem login → remova
app/middleware/auth.ts,app/pages/login.vuee o redirect de 401 nouseApi; login futuro → mantenha os pontos de encaixe como estão e não instale nada. - Limpe os exemplos: vitrine
/components(mantê-la como styleguide interno é válido; se remover, tire o link do header),app/pages/index.vue, marca noapp/layouts/default.vue,app/stores/app.ts. Ao final, caça-marca:grep -ri "nuxt base" app/ server/— a marca vive também emerror.vuee nosuseSeoMeta; zere o resultado. - Docs e ambiente: título/descrição de README e CLAUDE.md (as convenções continuam valendo), remova esta seção (já cumprida),
.env.examplesó com as variáveis reais do projeto (fora as das receitas não adotadas) e copie para.env. - Valide:
pnpm install && pnpm devsem erro e sem warning — se a porta 3000 estiver ocupada o Nuxt escolhe outra, confira no log — e/api/healthreportando o nome novo;pnpm verifyinteiro verde. Feche com o commit inicial.
A suíte herdada continua valendo no projeto novo: os testes de referência (componente e composable), o smoke de regressão do kit e o teste-inventário de tokens.
Os tokens vivem em app/assets/css/main.css, em duas camadas:
- Camada 1 —
:root(tema claro) e.dark(tema escuro) definem os valores de cada token semântico. Trocar a identidade visual do projeto = editar só esses dois blocos. - Camada 2 —
@theme inlineregistra os tokens como utilities do Tailwind (bg-primary,text-foreground,rounded-box, …). Só é editada para criar token novo.
| Token | Papel |
|---|---|
background / foreground | fundo e texto da página |
card / card-foreground | superfícies elevadas (cards, modais) |
muted / muted-foreground | fundos discretos e texto secundário |
border | bordas e divisores |
primary / primary-hover / primary-foreground | ação principal |
destructive / destructive-foreground | ações destrutivas e erros |
ring | anel de foco |
--radius-box → rounded-box | raio de containers (cards, modais) |
--radius-field → rounded-field | raio de controles (botões, inputs) |
--font-sans | tipografia base |
Regra da casa: componentes e páginas usam apenas tokens semânticos — nunca cor bruta (bg-blue-600, hex). Se precisar de uma cor nova, crie um token.
O dark mode é do @nuxtjs/color-mode (classe dark no <html>, preferência persistida). O tema claro é o padrão da plataforma (preference: 'light' no nuxt.config.ts — primeira visita abre clara independentemente do SO); o toggle está no layout default e a escolha do usuário persiste: colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'. Em SSR, renderize ícones dependentes do tema dentro de <ClientOnly> (o valor efetivo só é conhecido no client).
A base vem com SSR ligado (bom para SEO e primeira pintura). Ajuste por tipo de projeto no nuxt.config.ts:
// Dashboard/painel 100% atrás de login → SPA globalexportdefaultdefineNuxtConfig({ssr: false,})// Misto: só a área logada vira SPA; o resto continua SSRexportdefaultdefineNuxtConfig({routeRules: {'/app/**': {ssr: false},// área logada renderiza só no client'/': {prerender: true},// landing gerada no build},})Site com SEO: mantenha o padrão da base e acrescente prerender/swr nas rotas que couber.
Toda chamada HTTP sai por app/composables/useApi.ts:
useApi()→ instância configurada de$fetchpara chamadas imperativas (handlers de evento, stores, middleware):constapi=useApi()constuser=awaitapi<User>('/users/42')awaitapi('/users/42',{method: 'PATCH',body: {name: 'Ana'}})
useApiData<T>(url, options?)→ wrapper deuseFetchcom a mesma instância, para data fetching SSR-friendly no setup de componentes:const{data: users, status, error }=awaituseApiData<User[]>('/users')
Comportamento embutido:
- baseURL vem de
runtimeConfig.public.apiBase(default/api, o Nitro do próprio app). Para consumir API externa, definaNUXT_PUBLIC_API_BASE=https://api.exemplo.comno.env. - Credencial: se o cookie
auth.tokentiver valor, toda chamada sai comAuthorization: Bearer <token>(ver receita 2 de auth). Sem cookie, nenhum header é enviado. - Erros padronizados: respostas de erro rejeitam com
createErrorno formatoApiError—{ statusCode, statusMessage, data }. - 401 no client redireciona para
/login(remova ou ajuste nouseApise o projeto não usar esse fluxo).
Healthcheck de referência: GET /api/health → { status: 'ok', service: 'nuxt-base', timestamp }. Novos endpoints Nitro seguem o formato de server/api/health.get.ts (arquivo <nome>.<método>.ts + retorno tipado).
A base não implementa autenticação — entrega o middleware auth (esqueleto em app/middleware/auth.ts), a página /login placeholder e o useApi preparado para injetar credencial. Proteja páginas com definePageMeta({ middleware: 'auth' }) e escolha uma receita:
Sessão em cookie selado/criptografado, gerenciada pelo próprio Nitro. Recomendação oficial do time Nuxt.
npx nuxi module add auth-utilsDefina NUXT_SESSION_PASSWORD no .env (mínimo 32 caracteres; em dev é gerada automaticamente no primeiro nuxt dev, em produção é obrigatória — já documentada no .env.example).
// server/api/auth/login.post.tsexportdefaultdefineEventHandler(async(event)=>{const{ email, password }=awaitreadBody(event)constuser=awaitverifyCredentials(email,password)// sua validação (banco, serviço, …)awaitsetUserSession(event,{user: {email: user.email}})return{ok: true}})No client, useUserSession() expõe { loggedIn, user, session, clear }. Em app/middleware/auth.ts, descomente o bloco da receita 1:
const{ loggedIn }=useUserSession()if(!loggedIn.value){returnnavigateTo('/login')}Em rotas de API protegidas, use await requireUserSession(event) no início do handler.
O login troca credenciais por um token na API externa e o grava no cookie auth.token — o mesmo que o useApi já lê para injetar o Bearer.
Aponte a base para a API externa no .env: NUXT_PUBLIC_API_BASE=https://api.exemplo.com.
// essência da página de loginconstemail=ref('')constpassword=ref('')consttoken=useCookie('auth.token',{maxAge: 60*60*24*7,sameSite: 'lax'})asyncfunctionsubmit(){constapi=useApi()constresponse=awaitapi<{token: string}>('/auth/login',{method: 'POST',body: {email: email.value,password: password.value},})token.value=response.tokenawaitnavigateTo('/')}Em app/middleware/auth.ts, descomente o bloco da receita 2:
consttoken=useCookie('auth.token')if(!token.value){returnnavigateTo('/login')}A partir daí toda chamada do useApi sai autenticada; um 401 da API derruba o usuário de volta para /login (comportamento já embutido). Logout = useCookie('auth.token').value = null.
O kit vive em app/components/ui/ e é auto-importado com prefixo Ui (<UiButton>, <UiModal>, …). A vitrine com todos os componentes em uso está em /components.
| Componente | Props essenciais | Slots |
|---|---|---|
UiButton | variant (solid | outline | ghost | destructive), size (sm | md | lg), disabled, type | default |
UiInput | v-model, label, hint, error, type, placeholder, disabled | — |
UiSelect | v-model, items: { label, value, disabled? }[], label, placeholder, error, disabled | — |
UiModal | v-model:open, title (obrigatória), description | trigger, default, footer |
UiCard | — | header, default, footer |
UiBadge | variant (neutral | primary | destructive | outline) | default |
UiTable | columns: { key, label }[], rows | #cell-[key] recebe { row, value } |
UiToaster | montado uma única vez no layout default | — |
Toasts são imperativos, via composable: useToast() retorna { toasts, dismiss, success, error, info } — ex.: toast.success('Salvo.', { title: 'Pronto', duration: 8000 }).
Regra do copy-and-own (SPEC §2 e §4): o comportamento vem do Reka UI (headless — foco, teclado e ARIA resolvidos; esse sim é dependência) e o visual é markup portado do Preline UI, adaptado aos nossos tokens. Preline nunca entra como dependência — nem o pacote npm, nem o plugin JS; é catálogo de referência e fonte de cópia. Componente novo segue o mesmo caminho: escolher o primitivo Reka, portar o markup do Preline, traduzir variantes hs-* para os estados data-[state=…] do Reka e usar apenas tokens semânticos. Visual inspirado no Preline UI (MIT).
Stack: Vitest 4 + @nuxt/test-utils + happy-dom, com ambiente nuxt global (vitest.config.ts) — todo teste roda com o runtime do Nuxt (auto-imports, plugins, #components, #imports).
Dois testes de referência definem o padrão da casa — copie a estrutura deles:
- Componente →
tests/nuxt/components/button.spec.ts:mountSuspendedde@nuxt/test-utils/runtime+ import do componente via#components. Cobre render de slot, classes por variante e estado disabled. - Composable/API →
tests/nuxt/composables/use-api.spec.ts:registerEndpointde@nuxt/test-utils/runtimemocka rotas no Nitro de teste — sem rede real. Atenção: registre o caminho completo, incluindo o prefixo da baseURL (/api/ping, não/ping). Cobre resposta feliz tipada, erro padronizado e a injeção condicional do headerAuthorization.
Há ainda um teste-inventário (tests/nuxt/conventions/semantic-tokens.spec.ts) que trava a convenção "apenas tokens semânticos" em código: cor bruta nova em app/**/*.vue falha o CI, e entrada de allowlist que apodreceu também.
pnpm test# uma execução (é o que o CI roda)
pnpm test:watch # watch mode durante o desenvolvimentomainúnica — o template não usadevelop: o "Use this template" copia a branch default, e é ela que os gates mantêm sempre estável. Mudança arriscada = branch de feature ad hoc + PR (CI verde antes do merge), sem branch permanente.- Tags de versão (
v1.0.0, …) marcam estados estáveis da base — um projeto derivado sabe de qual versão nasceu. - Projetos derivados decidem o próprio fluxo conforme a realidade de deploy (trunk-based,
develop → main, preview environments) — a base não impõe.
- Hooks de git (local, via
lefthook.yml): pre-commit aplicaeslint --fixnos arquivos staged; pre-push rodapnpm verify— o espelho exato do CI. Instalados automaticamente nopnpm install(postinstall do lefthook); após zerar o.git, rodepnpm exec lefthook install. pnpm verify:lint && typecheck && test && knip && dupnum comando. Regra da casa: "deveria funcionar" não é terminado — rode antes de considerar qualquer tarefa pronta.- CI (
.github/workflows/ci.yml): a cada push namaine em todo PR —pnpm install --frozen-lockfile,lint,typecheck,knip,dup,testebuild, em Node 24 comTZ: UTC(teste sensível a data falha igual aqui e em produção). - Auditoria semanal (
.github/workflows/security.yml):pnpm audit --prodcomo gate toda segunda; auditoria completa informativa. - Renovate (
renovate.json+.github/workflows/renovate.yml): updates não-major agrupados às segundas; majors exigem aprovação no Dependency Dashboard; major do TypeScript bloqueado (pino em 6.x). Roda self-hosted via Actions (segunda 06:00 e manual via workflow_dispatch), com o secretRENOVATE_TOKEN(token do dono — PRs disparam o CI normalmente; sem app externo). O workflow do template também cobre os derivados listados emRENOVATE_REPOSITORIES. - Anti-duplicação e código morto: regras
sonarjsno ESLint (funções/branches idênticos), knip (exports, arquivos e dependências sem uso — config emknip.jsonc) e jscpd (pnpm dup— detector de copy-paste emapp/,server/etests/, config em.jscpd.json). O threshold é 0: a base parte de 0,00% de duplicação e qualquer clone novo falha o CI — subir o threshold num projeto derivado é decisão consciente, documentada no.jscpd.json. - Anti-vazamento de logs:
no-consoleno ESLint — erro emserver/**(log de servidor estruturado é decisão do derivado; receita: pino) e aviso emapp/(sóconsole.warn/console.errorliberados). No build de produção do client,console.log/info/debug/tracesão removidos do bundle (terserpure_funcs, só em$productionnonuxt.config.ts);warn/errorsobrevivem de propósito. - Varredura de segredos: job
gitleaksno CI varre o histórico inteiro a cada push/PR (chaves, tokens, senhas commitados por acidente). Segredo detectado = CI vermelho → remova, rotacione a credencial (ela já vazou no histórico) e reescreva o histórico se o repo for público..env/.env.*são gitignorados; só.env.example(com placeholders) é versionado.