name: Nuxt 4 Frontend Development description: Expert guidance for Nuxt 4 projects with app/ directory structure, Vue 3 Composition API, TypeScript, Vitest, ESLint, and Tailwind CSS 4 with Vite plugin. Use when creating components, composables, tests, or any frontend code. Enforces Tailwind-only styling (no inline styles or custom CSS). Supports Nuxt layers. version: 1.0.0
Nuxt 4 Frontend Development Skill
Overview
This skill guides development of modern, type-safe frontend applications using:
-
Nuxt 4 - Latest version with
app/directory structure, improved TypeScript support and auto-imports -
Vue 3 - Composition API with
<script setup>syntax - TypeScript - Strict mode enabled for maximum type safety
- Vitest - Fast unit testing with Vue Test Utils
- ESLint - Code quality and consistency
- Tailwind CSS 4 - Utility-first styling with Vite plugin (NO inline styles or custom CSS allowed)
When to Use This Skill
Use this skill when:
- Creating new Nuxt 4 components, composables, or utilities in the
app/directory - Writing tests for Vue components or composables
- Setting up or modifying project configuration
- Implementing new features following project conventions
- Styling components (MUST use Tailwind classes only, NO inline styles or custom CSS)
- Organizing code with Nuxt layers
- Setting up Tailwind 4 with the Vite plugin
Project Structure
Directory Organization
Nuxt 4 uses the app/ directory as the primary source directory. All application code lives under app/:
project/
├── app/
│ ├── components/
│ │ ├── ui/ # Reusable UI components (buttons, inputs, etc.)
│ │ ├── features/ # Feature-specific components
│ │ └── layouts/ # Layout components
│ ├── composables/ # Reusable composition functions
│ ├── utils/ # Pure utility functions
│ ├── types/ # TypeScript type definitions
│ ├── pages/ # File-based routing
│ ├── layouts/ # Application layouts
│ ├── middleware/ # Route middleware
│ ├── plugins/ # Vue plugins
│ ├── assets/ # Assets to be processed (CSS, images)
│ │ └── css/
│ │ └── main.css # Tailwind imports
│ └── app.vue # Root application component
├── server/ # Server API routes and middleware (stays at root)
│ ├── api/
│ ├── middleware/
│ └── utils/
├── layers/ # Nuxt layers for code organization
│ └── base/ # Example: shared base layer
├── tests/ # Test utilities and fixtures
│ ├── unit/
│ └── integration/
├── public/ # Static assets (not processed)
└── nuxt.config.ts # Nuxt configuration
Key Points:
-
All application code goes in
app/- components, composables, pages, layouts, etc. -
Server code stays at root level in
server/directory -
Use
app/assets/for assets that need processing (Tailwind CSS, images) -
Use
public/for static files served as-is
Naming Conventions
-
Components: PascalCase (e.g.,
app/components/UserProfile.vue,app/components/ui/BaseButton.vue) -
Composables: camelCase with
useprefix (e.g.,app/composables/useAuth.ts,app/composables/useFetchData.ts) -
Utils: camelCase (e.g.,
app/utils/formatDate.ts,app/utils/validateEmail.ts) -
Types: PascalCase for interfaces/types (e.g.,
User,ApiResponse) -
Pages: kebab-case (e.g.,
app/pages/user-profile.vue,app/pages/about-us.vue) -
Layouts: kebab-case (e.g.,
app/layouts/default.vue,app/layouts/admin.vue) -
Test files: Match source file with
.test.tsor.spec.tssuffix
Nuxt Layers
Nuxt layers allow you to organize and share code across projects. Use layers for:
- Shared UI components and composables
- Base configuration and setup
- Theme systems
- Multi-tenant applications
Layer Structure:
layers/
├── base/ # Shared base layer
│ ├── app/
│ │ ├── components/
│ │ ├── composables/
│ │ └── utils/
│ └── nuxt.config.ts # Layer-specific config
└── admin/ # Admin-specific layer
├── app/
│ ├── components/
│ └── pages/
└── nuxt.config.ts
Using Layers in nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'./layers/base',
'./layers/admin'
]
})
Layer Best Practices:
- Each layer should have its own
nuxt.config.ts - Layers can extend other layers
- Components, composables, and utils from layers are auto-imported
- Layer order matters - later layers override earlier ones
- Keep layers focused on specific domains or features
- Document layer dependencies clearly
Example Base Layer Config:
// layers/base/nuxt.config.ts
export default defineNuxtConfig({
components: {
dirs: [
{
path: '~/components',
global: true
}
]
}
})
Code Standards
TypeScript Configuration
Always use strict TypeScript settings:
// nuxt.config.ts
export default defineNuxtConfig({
typescript: {
strict: true,
typeCheck: true,
}
})
Type Definition Best Practices:
- Define interfaces in
types/directory for shared types - Use
typefor unions, intersections, and simple aliases - Use
interfacefor object shapes that may be extended - Export types from a central
types/index.tsfor easy imports - Always type function parameters and return values
Vue 3 Component Patterns
Preferred Component Structure:
<script setup lang="ts">
// 1. Imports
import { ref, computed, onMounted } from 'vue'
import type { User } from '~/types'
// 2. Props and Emits
interface Props {
userId: string
isActive?: boolean
}
interface Emits {
update: [user: User]
close: []
}
const props = withDefaults(defineProps<Props>(), {
isActive: true
})
const emit = defineEmits<Emits>()
// 3. Composables
const { data: user, pending } = await useFetch(`/api/users/${props.userId}`)
// 4. Reactive State
const isEditing = ref(false)
// 5. Computed Properties
const displayName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
// 6. Methods
const handleUpdate = () => {
if (user.value) {
emit('update', user.value)
}
}
// 7. Lifecycle
onMounted(() => {
console.log('Component mounted')
})
</script>
<template>
<div class="container">
<!-- Template content -->
</div>
</template>
Key Principles:
- Always use
<script setup>for Composition API - Use
lang="ts"on script tags - Destructure props carefully (use
.valuewhen needed) - Prefer
computedover methods for derived state - Use
definePropswith TypeScript interfaces, not runtime props
Composables Patterns
Structure for Reusable Composables:
// composables/useAuth.ts
import { ref, computed } from 'vue'
import type { User } from '~/types'
export const useAuth = () => {
// State
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
// Computed
const isAuthenticated = computed(() => !!user.value)
// Methods
const login = async (email: string, password: string) => {
isLoading.value = true
error.value = null
try {
const response = await $fetch<User>('/api/auth/login', {
method: 'POST',
body: { email, password }
})
user.value = response
} catch (e) {
error.value = e as Error
throw e
} finally {
isLoading.value = false
}
}
const logout = async () => {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
}
// Return public API
return {
user: readonly(user),
isAuthenticated,
isLoading: readonly(isLoading),
error: readonly(error),
login,
logout
}
}
Composable Best Practices:
- Always return an object with named properties
- Use
readonly()for state that shouldn't be mutated externally - Include loading and error states for async operations
- Provide TypeScript types for all parameters and return values
- Keep composables focused on a single responsibility
Data Fetching Patterns
Preferred Nuxt 4 Data Fetching:
// Good: Using useFetch with auto-typed response
const { data, pending, error, refresh } = await useFetch('/api/users', {
query: { limit: 10 }
})
// Good: Using useAsyncData for more control
const { data: users } = await useAsyncData(
'users-list',
() => $fetch<User[]>('/api/users')
)
// Good: Lazy loading with explicit type
const { data, pending } = useLazyFetch<Product>(`/api/products/${id}`)
Data Fetching Rules:
- Use
useFetchfor simple API calls - Use
useAsyncDatawhen you need custom async logic - Add unique keys to
useAsyncDatato manage cache - Always await in
<script setup>to enable SSR - Use
useLazyFetchfor client-side only or lazy loading - Type the response explicitly when TypeScript can't infer
Tailwind CSS Guidelines
CRITICAL: We use Tailwind 4 with the Vite plugin. ALWAYS use Tailwind utility classes. NEVER use inline styles or custom CSS that could be achieved with Tailwind classes.
Tailwind 4 Setup:
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
plugins: [
// Tailwind 4 uses Vite plugin instead of PostCSS
]
},
css: ['~/assets/css/main.css']
})
/* app/assets/css/main.css */
@import "tailwindcss";
/* Custom design tokens (if needed) */
@theme {
--color-primary: #3b82f6;
--color-secondary: #8b5cf6;
--font-display: 'Inter', sans-serif;
}
Mandatory Class Usage Rules:
❌ NEVER do this:
<!-- BAD: Inline styles -->
<div style="padding: 16px; background-color: blue;">
<!-- BAD: Custom CSS that could be Tailwind -->
<style scoped>
.my-button {
padding: 1rem;
background-color: #3b82f6;
border-radius: 0.5rem;
}
</style>
<!-- BAD: Arbitrary CSS properties -->
<div class="[background:linear-gradient(to-right,#fff,#000)]">
✅ ALWAYS do this:
<!-- GOOD: Pure Tailwind classes -->
<div class="p-4 bg-blue-500">
<button class="px-4 py-2 bg-blue-600 rounded-lg">
<!-- GOOD: Use Tailwind's gradient utilities -->
<div class="bg-gradient-to-r from-white to-black">
Class Organization: Order classes by category for better readability:
-
Layout:
flex,grid,block,inline-flex -
Positioning:
relative,absolute,top-0,left-0 -
Spacing:
p-4,m-2,space-x-4,gap-4 -
Sizing:
w-full,h-screen,max-w-lg -
Typography:
text-sm,font-bold,leading-tight -
Colors:
text-gray-900,bg-blue-600 -
Borders:
border,border-gray-300,rounded-lg -
Effects:
shadow-lg,opacity-50 -
Transitions:
transition-all,duration-300 -
States:
hover:bg-blue-700,focus:ring-2,disabled:opacity-50
Example:
<template>
<button
class="
flex items-center justify-center
px-6 py-3
w-full max-w-xs
text-base font-semibold text-white
bg-blue-600 rounded-xl
shadow-md
transition-all duration-200
hover:bg-blue-700 hover:shadow-lg
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
active:scale-95
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600
"
:disabled="isLoading"
>
<LoadingSpinner v-if="isLoading" class="mr-2 h-4 w-4" />
{{ isLoading ? 'Processing...' : 'Submit' }}
</button>
</template>
Responsive Design:
<template>
<div class="
grid grid-cols-1
sm:grid-cols-2
md:grid-cols-3
lg:grid-cols-4
gap-4
p-4
sm:p-6
lg:p-8
">
<!-- Content -->
</div>
</template>
When to Extract Components: If you're repeating the same class combinations across multiple components, extract them into a reusable component:
<!-- app/components/ui/PrimaryButton.vue -->
<template>
<button
class="
px-6 py-3
text-base font-semibold text-white
bg-blue-600 rounded-xl
hover:bg-blue-700
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
"
v-bind="$attrs"
>
<slot />
</button>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
</script>
DO NOT use @apply or custom CSS:
/* ❌ BAD - Don't do this */
<style scoped>
.btn-primary {
@apply px-6 py-3 bg-blue-600 text-white rounded-xl;
}
</style>
/* ✅ GOOD - Use Tailwind classes directly in template */
Tailwind 4 Configuration:
// tailwind.config.ts (if needed for custom values)
export default {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
}
},
spacing: {
'128': '32rem',
}
}
}
}
Custom Properties with Tailwind 4:
Use CSS custom properties in @theme for dynamic theming:
/* app/assets/css/main.css */
@import "tailwindcss";
@theme {
--color-brand-primary: oklch(0.5 0.2 240);
--color-brand-secondary: oklch(0.6 0.15 280);
--radius-default: 0.5rem;
}
Then use in templates:
<div class="bg-brand-primary text-white rounded-[--radius-default]">
Key Principles:
- NEVER write custom CSS that could be Tailwind utilities
- NEVER use inline
styleattributes - Extract repeated patterns to reusable components, NOT CSS
- Use Tailwind's spacing scale (don't use arbitrary values unless absolutely necessary)
- Leverage Tailwind's color palette - only extend when brand requires specific colors
- Always use responsive prefixes for mobile-first design
ESLint Configuration
Expected ESLint Setup:
// .eslintrc.cjs or eslint.config.js
module.exports = {
extends: [
'@nuxt/eslint-config',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
rules: {
'vue/multi-word-component-names': 'error',
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}
Testing with Vitest
Test File Structure
// components/UserCard.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
import type { User } from '~/types'
describe('UserCard', () => {
const mockUser: User = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
}
it('renders user information correctly', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john@example.com')
})
it('emits delete event when delete button clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})
await wrapper.find('[data-test="delete-btn"]').trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')?.[0]).toEqual([mockUser.id])
})
})
Composable Testing
// composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('increments counter', () => {
const { count, increment } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('decrements counter', () => {
const { count, decrement } = useCounter(5)
expect(count.value).toBe(5)
decrement()
expect(count.value).toBe(4)
})
})
Testing Best Practices
- Test user behavior, not implementation details
- Use
data-testattributes for reliable element selection - Mock external dependencies (APIs, composables)
- Test edge cases and error states
- Keep tests isolated and independent
- Use
describeblocks to organize related tests - Write descriptive test names that explain the expected behavior
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts']
},
resolve: {
alias: {
'~': fileURLToPath(new URL('./', import.meta.url)),
'@': fileURLToPath(new URL('./', import.meta.url))
}
}
})
Common Patterns
Form Handling
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
type FormData = z.infer<typeof schema>
const form = reactive<FormData>({
email: '',
password: ''
})
const errors = ref<Partial<Record<keyof FormData, string>>>({})
const isSubmitting = ref(false)
const handleSubmit = async () => {
errors.value = {}
const result = schema.safeParse(form)
if (!result.success) {
result.error.issues.forEach(issue => {
if (issue.path[0]) {
errors.value[issue.path[0] as keyof FormData] = issue.message
}
})
return
}
isSubmitting.value = true
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: form
})
} catch (e) {
console.error(e)
} finally {
isSubmitting.value = false
}
}
</script>
Error Handling
// utils/error-handler.ts
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public code?: string
) {
super(message)
this.name = 'ApiError'
}
}
export const handleApiError = (error: unknown): string => {
if (error instanceof ApiError) {
return error.message
}
if (error instanceof Error) {
return error.message
}
return 'An unexpected error occurred'
}
Loading States
<script setup lang="ts">
const { data, pending, error } = await useFetch('/api/data')
</script>
<template>
<div>
<div v-if="pending" class="flex justify-center p-8">
<LoadingSpinner />
</div>
<div v-else-if="error" class="text-red-600">
{{ error.message }}
</div>
<div v-else-if="data">
<!-- Render data -->
</div>
</div>
</template>
Anti-Patterns (Don't Do This)
❌ Avoid Options API
<!-- BAD -->
<script lang="ts">
export default {
data() {
return { count: 0 }
},
methods: {
increment() { this.count++ }
}
}
</script>
<!-- GOOD -->
<script setup lang="ts">
const count = ref(0)
const increment = () => count.value++
</script>
❌ Don't Use any Type
// BAD
const fetchData = async (): Promise<any> => { ... }
// GOOD
const fetchData = async (): Promise<User[]> => { ... }
❌ Avoid Mutating Props
<script setup lang="ts">
const props = defineProps<{ count: number }>()
// BAD
const increment = () => props.count++
// GOOD
const emit = defineEmits<{ increment: [] }>()
const increment = () => emit('increment')
</script>
❌ NEVER Use Inline Styles
<!-- BAD - Inline styles are forbidden -->
<div style="padding: 16px; background-color: blue;">
<div :style="{ padding: '16px', backgroundColor: 'blue' }">
<div :style="computedStyles">
<!-- GOOD - Always use Tailwind classes -->
<div class="p-4 bg-blue-500">
❌ NEVER Write Custom CSS for Tailwind-Available Styles
<!-- BAD - Don't write CSS that Tailwind already provides -->
<style scoped>
.my-container {
display: flex;
align-items: center;
padding: 1rem;
background-color: #3b82f6;
border-radius: 0.5rem;
}
</style>
<!-- GOOD - Use Tailwind utilities -->
<div class="flex items-center p-4 bg-blue-500 rounded-lg">
❌ Don't Use @apply Directive
<!-- BAD - Avoid @apply, use classes directly -->
<style scoped>
.btn {
@apply px-4 py-2 bg-blue-600 text-white rounded-lg;
}
</style>
<!-- GOOD - Component extraction for reuse -->
<!-- app/components/ui/Button.vue -->
<template>
<button class="px-4 py-2 bg-blue-600 text-white rounded-lg">
<slot />
</button>
</template>
❌ Don't Use Nuxt 2 Patterns
// BAD (Nuxt 2)
export default {
asyncData({ $axios }) {
return $axios.get('/api/users')
}
}
// GOOD (Nuxt 4)
const { data: users } = await useFetch('/api/users')
❌ Avoid Deep Prop Drilling
// BAD - passing data through many layers
<ComponentA :user="user" />
<ComponentB :user="user" />
<ComponentC :user="user" />
// GOOD - use composables or provide/inject
// In parent
provide('user', user)
// In deep child
const user = inject<User>('user')
❌ Don't Use Old Folder Structure
// BAD (Nuxt 3 and earlier)
components/MyComponent.vue
composables/useAuth.ts
pages/index.vue
// GOOD (Nuxt 4)
app/components/MyComponent.vue
app/composables/useAuth.ts
app/pages/index.vue
Quick Reference Commands
# Development
npm run dev
# Type checking
npm run typecheck
# Linting
npm run lint
npm run lint:fix
# Testing
npm run test
npm run test:watch
npm run test:coverage
# Build
npm run build
npm run preview
Additional Notes
- Always run
npm run typecheckbefore committing - Use
console.logsparingly; prefer debugging tools - Keep components under 200 lines; split if larger
- Write tests for all business logic and complex components
- Document complex logic with comments
- Use TypeScript's utility types (
Partial,Pick,Omit, etc.) -
CRITICAL: NEVER use inline styles (
styleattribute or:stylebinding) - CRITICAL: NEVER write custom CSS that could be Tailwind utility classes
- CRITICAL: All styling MUST use Tailwind utility classes directly in templates
- All application code goes in the
app/directory (Nuxt 4 convention) - Use Nuxt layers for shared code and multi-tenant applications
- Extract repeated Tailwind patterns into reusable components, not CSS classes
Resources
chat Comments (0)
Sign in to join the discussion and leave a comment.
Skill Details
Related Skills
Build your own?
Join 12,000+ developers contributing to the Claude ecosystem.
No comments yet. Be the first to share your thoughts!