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.
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.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.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.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.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.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.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.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).@nuxt/test-utils mountSuspended for a component using useFetch with registerEndpoint, and $fetch against setup()'s server for the endpoint.Drill 1 in full: schemas shared, handlers thin, page reactive.
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>
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 }
})
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
})
<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.
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 }
})
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
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.
<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.
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 } }
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.
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 }
}
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.
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.
createError field names (status/statusText in Nuxt 4.5, statusCode/statusMessage deprecated), navigateTo return semantics, and nuxt-auth-utils' current helper names.
npm create nuxt, implement drill 1 with an in-memory store in 30 minutes, then add registerEndpoint tests with @nuxt/test-utils.nuxt-auth-utils; verify with curl that the protected endpoint returns 401 without the cookie while the page redirects.curl -I that a bad slug returns 404 and a good one has the meta tags in the HTML."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Vue component drills
The component and composable builds that senior Vue interviews ask for in 30–45 minutes — a todo list with filters, an accessible autocomplete, a dialog with focus trapping, tabs, a sortable paginated table, an infinite list, a validated form — and the composables behind them (useDebounce, useLocalStorage, useIntersectionObserver, useEventListener), with the details reviewers look for.
Algorithms at frontend depth
The algorithmic knowledge a senior frontend interview actually tests — complexity reasoning, hash maps and sets, two pointers and sliding windows, stacks and queues, trees and DOM traversal, recursion versus iteration, binary search, tries for autocomplete, light dynamic programming for fuzzy matching — with JavaScript implementations, the frontend situations each one shows up in, and how to talk about them.