Coding drills

Nuxt drills

Fullstack Nuxt exercises in the shape interviewers use — a validated API route plus a page that consumes it, a protected area with session middleware, an error page, a dynamic SEO route, cached list endpoints with route rules, a plugin that provides a helper, shared state across components, and a mini layer — each with the specification to state, the solution core, and what is checked.

Nuxt live drills usually start from a fresh npm create nuxt and ask you to add one vertical slice in 30–60 minutes: an endpoint, a page, some state, a guard. What is being checked is idiomatic Nuxt: file conventions, useFetch with keys, validation in the handler, createError with status, SSR safety, and knowing which utility exists so you do not reinvent it.

Know

  • Drill 1 — validated list endpoint plus page: server/api/todos/index.get.ts with getValidatedQuery (zod), returns { items, next }; server/api/todos/index.post.ts with readValidatedBody; page with useFetch('/api/todos', { query }) reactive to filters, refresh() after a mutation, pending and error states, an Idempotency-Key-free but disabled-while-pending submit. Checked: keys, pick/transform for payload size, error UI, no fetch in onMounted.
  • Drill 2 — protected area: nuxt-auth-utils login route with a password check, app/middleware/auth.ts redirecting anonymous users with navigateTo('/login', { redirectCode: 302 }) (return value, not thrown), definePageMeta({ middleware: 'auth' }), requireUserSession in every protected handler, useUserSession in the layout to show the user. Checked: client middleware is UX only; the handler check is the security.
  • Drill 3 — error handling: app/error.vue with clearError({ redirect: '/' }), createError({ status: 404, statusText: 'Not found' }) (Nuxt 4.5 field names) from a page when data is missing, <NuxtErrorBoundary> around a risky widget, showError for fatal client errors, a global vue:error hook to report. Checked: fatal: true vs non-fatal, 404 status actually sent during SSR.
  • Drill 4 — dynamic SEO route: pages/products/[slug].vue with useAsyncData keyed by slug, useSeoMeta with title, description, ogImage, canonical via useHead, definePageMeta({ validate }) returning false for bad slugs (404), routeRules swr for the route, and defineOgImage when nuxt-og-image is present. Checked: server-rendered meta, no flash of default title, correct 404.
  • Drill 5 — caching: routeRules: { '/api/products': { cache: { maxAge: 60, swr: true } } } or defineCachedEventHandler, cachedFunction for an upstream call, Cache-Control headers for personalised routes, and an invalidation route. Checked: personalised data never cached, understanding of shared storage in production.
  • Drill 6 — plugin providing a helper: app/plugins/format.ts with defineNuxtPlugin(() => ({ provide: { money: (cents: number) => new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100) } })), typed via declare module '#app' { interface NuxtApp { $money: ... } } and declare module 'vue' { interface ComponentCustomProperties { $money: ... } }. Checked: SSR-safe (no window), useNuxtApp().$money, or, better, a composable instead of a plugin when no app-wide setup is needed.
  • Drill 7 — shared state: useState('cart', () => ({ items: [] })) used by a header badge and a product page; a useCart() composable wrapping it with actions; persistence to a cookie via useCookie for SSR-visible state; contrast with a Pinia store when logic grows. Checked: no module-scope state, SSR hydration of the state, cookie size limits.
  • Drill 8 — mini layer: layers/brand/nuxt.config.ts with app.config tokens and a shared component; the app overrides one component by file path; show nuxt.options._layers order in a module. Checked: understanding of override precedence (the layers section has the details).
  • Testing hooks: @nuxt/test-utils mountSuspended for a component using useFetch with registerEndpoint, and $fetch against setup()'s server for the endpoint.

How it works

Drill 1 in full: schemas shared, handlers thin, page reactive.

shared/schemas/todo.ts
import { z } from 'zod'

export const todoSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).max(200), done: z.boolean(), createdAt: z.string().datetime() })
export const createTodoSchema = todoSchema.pick({ title: true })
export const listTodosQuery = z.object({ filter: z.enum(['all', 'active', 'done']).default('all'), cursor: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(50).default(20) })
export type Todo = z.infer<typeof todoSchema>
server/api/todos/index.get.ts
export default defineEventHandler(async (event) => {
  const { filter, cursor, limit } = await getValidatedQuery(event, listTodosQuery.parse)
  const page = await listTodos(useDb(), { filter, cursor, limit })   // service: db in, DTOs out
  return { items: page.items, next: page.nextCursor }
})
server/api/todos/index.post.ts
export default defineEventHandler(async (event) => {
  const { title } = await readValidatedBody(event, createTodoSchema.parse)
  const todo = await createTodo(useDb(), { title })
  setResponseStatus(event, 201)
  setResponseHeader(event, 'location', `/api/todos/${todo.id}`)
  return todo
})
app/pages/todos.vue
<script setup lang="ts">
import type { Todo } from '~~/shared/schemas/todo'

const route = useRoute()
const router = useRouter()
const filter = computed({
  get: () => (['all', 'active', 'done'].includes(String(route.query.filter)) ? String(route.query.filter) : 'all') as 'all' | 'active' | 'done',
  set: value => router.replace({ query: { ...route.query, filter: value === 'all' ? undefined : value } }),
})

const { data, status, error, refresh } = await useFetch('/api/todos', {
  query: { filter },                 // reactive: refetches when the filter changes
  key: 'todos',
  default: () => ({ items: [] as Todo[], next: null as string | null }),
})

const title = ref('')
const submitting = ref(false)
async function add() {
  if (!title.value.trim() || submitting.value) return
  submitting.value = true
  try {
    await $fetch('/api/todos', { method: 'POST', body: { title: title.value.trim() } })
    title.value = ''
    await refresh()
  }
  finally { submitting.value = false }
}
</script>

<template>
  <form @submit.prevent="add">
    <label for="new-todo">New todo</label>
    <input id="new-todo" v-model="title" :disabled="submitting" required maxlength="200">
    <button :disabled="submitting || !title.trim()">Add</button>
  </form>
  <nav aria-label="Filter">
    <button v-for="f in ['all', 'active', 'done'] as const" :key="f" :aria-pressed="filter === f" @click="filter = f">{{ f }}</button>
  </nav>
  <p v-if="error" role="alert">{{ error.statusText ?? 'Could not load todos' }} <button @click="refresh()">Retry</button></p>
  <ul v-else :aria-busy="status === 'pending'">
    <li v-for="todo in data.items" :key="todo.id">{{ todo.title }}</li>
  </ul>
</template>

Drill 2: session, middleware, protected handler.

server/api/auth/login.post.ts
import { z } from 'zod'

export default defineEventHandler(async (event) => {
  const { email, password } = await readValidatedBody(event, z.object({ email: z.string().email(), password: z.string().min(8) }).parse)
  const user = await findUserByEmail(useDb(), email)
  if (!user || !(await verifyPassword(user.passwordHash, password))) {
    throw createError({ status: 401, statusText: 'Invalid credentials' })
  }
  await clearUserSession(event)
  await setUserSession(event, { user: { id: user.id, email: user.email, role: user.role }, loggedInAt: Date.now() })
  return { ok: true }
})
app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const { loggedIn } = useUserSession()
  if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
server/api/account/orders.get.ts
export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)         // 401 when missing: the real guard
  return listOrdersForUser(useDb(), user.id)
})

Drill 4: SEO route with validation and a 404 that is really a 404.

app/pages/products/[slug].vue
<script setup lang="ts">
definePageMeta({ validate: route => /^[a-z0-9-]{1,80}$/.test(String(route.params.slug)) })

const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.slug}`, { key: `product:${route.params.slug}` })
if (!product.value) throw createError({ status: 404, statusText: 'Product not found', fatal: true })

useSeoMeta({
  title: () => product.value?.name,
  description: () => product.value?.summary,
  ogTitle: () => product.value?.name,
  ogImage: () => product.value?.image,
})
useHead({ link: [{ rel: 'canonical', href: `https://shop.example.com/products/${route.params.slug}` }] })
</script>

Drill 6: a typed plugin, and the composable that is usually the better answer.

app/plugins/money.ts
export default defineNuxtPlugin(() => {
  const { locale, currency } = useAppConfig().intl
  const formatter = new Intl.NumberFormat(locale, { style: 'currency', currency })
  return { provide: { money: (cents: number) => formatter.format(cents / 100) } }
})

declare module '#app' { interface NuxtApp { $money: (cents: number) => string } }
declare module 'vue' { interface ComponentCustomProperties { $money: (cents: number) => string } }
app/composables/useMoney.ts
export function useMoney() {
  const { locale, currency } = useAppConfig().intl
  const formatter = new Intl.NumberFormat(locale, { style: 'currency', currency })
  return (cents: number) => formatter.format(cents / 100)      // no plugin needed; tree-shakeable, testable
}

Drill 7: shared cart state that survives SSR through a cookie.

app/composables/useCart.ts
export function useCart() {
  const cookie = useCookie<{ id: string, qty: number }[]>('cart', { default: () => [], maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
  const items = useState('cart', () => cookie.value)
  watch(items, v => (cookie.value = v), { deep: true })
  const count = computed(() => items.value.reduce((n, i) => n + i.qty, 0))
  function add(id: string, qty = 1) {
    const line = items.value.find(i => i.id === id)
    if (line) line.qty += qty
    else items.value.push({ id, qty })
  }
  return { items: readonly(items), count, add }
}
Gotcha· Throwing navigateTo in middleware

throw navigateTo('/login') works by accident in some versions and not others; the contract is return navigateTo(...). Likewise return abortNavigation(error); do not call navigateTo for side effects without returning it.

Gotcha· The 404 that returns 200

Rendering "Not found" text inside a page without createError sends a 200 to crawlers and caches. Throw createError({ status: 404 }) during setup (SSR), or use validate in definePageMeta, so the response status is correct.

Verify before the interview:

createError field names (status/statusText in Nuxt 4.5, statusCode/statusMessage deprecated), navigateTo return semantics, and nuxt-auth-utils' current helper names.

docs ↗

Exercise

Exercise
  • From npm create nuxt, implement drill 1 with an in-memory store in 30 minutes, then add registerEndpoint tests with @nuxt/test-utils.
  • Add drill 2 with nuxt-auth-utils; verify with curl that the protected endpoint returns 401 without the cookie while the page redirects.
  • Implement drill 4 and confirm with curl -I that a bad slug returns 404 and a good one has the meta tags in the HTML.

Be able to say

Be able to say· Add a todos API and page to a fresh Nuxt app. How do you structure it?

"A zod schema in shared/ used by both sides; server/api/todos/index.get.ts reading the filter, cursor and limit with getValidatedQuery, and index.post.ts with readValidatedBody returning 201 and a Location header, both delegating to a small service that takes the database. The page uses useFetch with a key and a reactive query bound to the filter, which lives in the URL so it is shareable; pending, error and empty states; the form disables while submitting and calls refresh() after the POST. Errors come from createError with a status and are shown by the page or the error boundary. Then tests: registerEndpoint for the component and $fetch against the test server for the route."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.