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 →