Explore
TypeScript in Nuxt: Patterns That Keep Your Code Maintainable

TypeScript in Nuxt: Patterns That Keep Your Code Maintainable

Nuxt has first-class TypeScript support. Using it well reduces bugs and makes your codebase significantly easier to work with. Here are the patterns worth knowing.

TypeScript in Nuxt: Patterns That Keep Your Code Maintainable

Nuxt ships with TypeScript support out of the box. The auto-imports are typed, the server routes have type inference, and the routing is type-safe. Using TypeScript well in Nuxt isn't about fighting the framework — it's about leaning into what's already there.

Here are the patterns that make a real difference to code quality and maintainability.


Type your API responses

Every server route in Nuxt can have a typed response. Define your types once; the client gets inference automatically:

// server/api/users/[id].get.ts
interface UserResponse {
  id: string
  name: string
  email: string
  plan: 'free' | 'pro' | 'enterprise'
  createdAt: string
}

export default defineEventHandler(async (event): Promise<UserResponse> => {
  const id = getRouterParam(event, 'id')
  const user = await db.users.findOne(id)

  return {
    id: user.id,
    name: user.name,
    email: user.email,
    plan: user.plan,
    createdAt: user.created_at
  }
})

When you use useFetch('/api/users/123') in a component, the data ref is typed as UserResponse | null. No manual type annotation required.


Typed route params

Nuxt 3.9+ generates types for route parameters automatically. Enable typed routing:

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    typedPages: true
  }
})

Now useRoute() returns typed params based on your file structure:

// In pages/users/[id].vue
const route = useRoute('users-id') // route.params.id is typed as string

Typed composables with generics

Composables that work with data of different types should use generics:

// composables/usePagination.ts
interface PaginationState<T> {
  items: Ref<T[]>
  total: Ref<number>
  page: Ref<number>
  perPage: Ref<number>
  loading: Ref<boolean>
  fetchPage: (page: number) => Promise<void>
}

export function usePagination<T>(
  fetcher: (page: number, perPage: number) => Promise<{ items: T[]; total: number }>,
  initialPerPage = 20
): PaginationState<T> {
  const items = ref<T[]>([]) as Ref<T[]>
  const total = ref(0)
  const page = ref(1)
  const perPage = ref(initialPerPage)
  const loading = ref(false)

  async function fetchPage(newPage: number) {
    loading.value = true
    const result = await fetcher(newPage, perPage.value)
    items.value = result.items
    total.value = result.total
    page.value = newPage
    loading.value = false
  }

  return { items, total, page, perPage, loading, fetchPage }
}

Usage:

const { items: users, fetchPage } = usePagination<User>(
  (page, perPage) => $fetch('/api/users', { query: { page, perPage } })
)

Discriminated unions for state

Instead of separate loading/error/data refs, use a discriminated union to represent async state cleanly:

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

const state = ref<AsyncState<User>>({ status: 'idle' })

async function loadUser(id: string) {
  state.value = { status: 'loading' }

  try {
    const data = await $fetch<User>(`/api/users/${id}`)
    state.value = { status: 'success', data }
  } catch (e) {
    state.value = { status: 'error', error: 'Failed to load user' }
  }
}

In the template, TypeScript's narrowing works correctly:

<template>
  <div v-if="state.status === 'loading'">Loading...</div>
  <div v-else-if="state.status === 'error'">{{ state.error }}</div>
  <!-- TypeScript knows state.data is User here -->
  <UserCard v-else-if="state.status === 'success'" :user="state.data" />
</template>

Type your Pinia stores

Pinia stores with TypeScript are self-documenting:

// stores/auth.ts
interface User {
  id: string
  email: string
  name: string
  avatarUrl: string | null
}

interface AuthState {
  user: User | null
  loading: boolean
}

export const useAuthStore = defineStore('auth', () => {
  const state = reactive<AuthState>({
    user: null,
    loading: false
  })

  const isAuthenticated = computed(() => state.user !== null)

  async function fetchUser(): Promise<void> {
    state.loading = true
    try {
      state.user = await $fetch<User>('/api/me')
    } finally {
      state.loading = false
    }
  }

  function logout(): void {
    state.user = null
  }

  return {
    ...toRefs(state),
    isAuthenticated,
    fetchUser,
    logout
  }
})

Zod for runtime validation

TypeScript types are erased at runtime. When data comes in from external sources (API responses, form submissions, URL params), validate it at the boundary with Zod:

// server/api/create-project.post.ts
import { z } from 'zod'

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
  visibility: z.enum(['public', 'private'])
})

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  const result = CreateProjectSchema.safeParse(body)
  if (!result.success) {
    throw createError({
      statusCode: 400,
      message: result.error.errors.map(e => e.message).join(', ')
    })
  }

  // result.data is fully typed as { name: string; description?: string; visibility: 'public' | 'private' }
  const project = await db.projects.create(result.data)
  return project
})

Zod schemas can also be used client-side for form validation, sharing the same schema between client and server.


Strict TypeScript config

Nuxt's default TypeScript config is good. Make it stricter:

// tsconfig.json
{
  "extends": "./.nuxt/tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

strict: true enables a bundle of checks including strictNullChecks, which catches the vast majority of null/undefined errors at compile time. The other options catch more edge cases.

These checks will surface real bugs. Fix them; don't cast or suppress them.


The return on TypeScript

TypeScript in Nuxt isn't overhead — it's insurance. The upfront cost of adding types is paid back every time a type error in CI catches a bug that would have appeared in production. And unlike manual testing, it runs on every change automatically.

Codebases with good TypeScript coverage are also significantly easier for new contributors to work in. The types are a form of documentation that's always up to date.

Building on Nuxt with TypeScript from day one? That's how we work →