Agent Skills: Nuxt 3 / Nitro API Patterns

Build type-safe Nuxt 3 applications with Nitro API patterns. Covers validation, fetch patterns, auth, SSR, composables, background tasks, and real-time features.

UncategorizedID: gallop-systems/claude-skills/nuxt-nitro-api

Install this agent skill to your local

pnpm dlx add-skill https://github.com/gallop-systems/claude-skills/tree/HEAD/plugins/nuxt-nitro-api/skills/nuxt-nitro-api

Skill Files

Browse the full folder contents for nuxt-nitro-api.

Download Skill

Loading file tree…

plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md

Skill Metadata

Name
nuxt-nitro-api
Description
Build type-safe Nuxt 3 applications with Nitro API patterns. Covers validation, fetch patterns, auth, SSR, composables, background tasks, and real-time features.

Nuxt 3 / Nitro API Patterns

This skill provides patterns for building type-safe Nuxt 3 applications with Nitro backends.

When to Use This Skill

Use this skill when:

  • Working in a Nuxt 3 project with TypeScript
  • Building API endpoints with Nitro
  • Implementing authentication with nuxt-auth-utils
  • Handling SSR + client-side state
  • Creating background tasks or real-time features

Reference Files

For detailed patterns, see these topic-focused reference files:

Example Files

Working examples from a Nuxt project:

Core Principles

  1. Let Nitro infer types - Never add manual type params to $fetch<Type>() or useFetch<Type>()
  2. Use h3 validation - getValidatedQuery(), readValidatedBody() with Zod schemas
  3. Composables for context, utils for pure functions - Composables access Nuxt context, utils are pure
  4. SSR-safe code - Guard browser APIs with import.meta.client or onMounted
  5. Keep pages thin - Pages = layout + route params + components. Components own data fetching and logic.

Auto-Imports Quick Reference

Server-side (/server directory)

All h3 utilities auto-imported:

  • defineEventHandler, createError, getQuery, getValidatedQuery
  • readBody, readValidatedBody, getRouterParams, getValidatedRouterParams
  • getCookie, setCookie, deleteCookie, getHeader, setHeader

From nuxt-auth-utils:

  • getUserSession, setUserSession, clearUserSession, requireUserSession
  • hashPassword, verifyPassword
  • defineOAuth*EventHandler (Google, GitHub, etc.)

Need to import: z from "zod", fromZodError from "zod-validation-error"

Client-side

All auto-imported:

  • Vue: ref, computed, watch, onMounted, etc.
  • VueUse: refDebounced, useLocalStorage, useUrlSearchParams, etc.
  • Nuxt: useFetch, useAsyncData, useRoute, useRouter, useState, navigateTo

Shared (/shared directory - Nuxt 3.14+)

Code auto-imported on both client AND server. Use for:

  • Types and interfaces
  • Pure utility functions
  • Constants

Quick Patterns

Validation (h3 v2+ with Standard Schema)

// Pass Zod schema directly (h3 v2+)
const query = await getValidatedQuery(event, z.object({
  search: z.string().optional(),
  page: z.coerce.number().default(1),
}));

const body = await readValidatedBody(event, z.object({
  email: z.string().email(),
  name: z.string().min(1),
}));

$fetch Type Inference

// Template literals preserve type inference (fixed late 2024)
const userId = "123";  // Literal type "123"
const result = await $fetch(`/api/users/${userId}`);
// result is typed from the handler's return type

// NEVER do this - defeats type inference
const result = await $fetch<User>("/api/users/123");  // WRONG

useFetch for Page Data

// Basic - types inferred from Nitro
const { data, status, refresh } = await useFetch("/api/users");

// Reactive query params - auto-refetch on change
const search = ref("");
const debouncedSearch = refDebounced(search, 300);  // Auto-imported
const { data } = await useFetch("/api/users", {
  query: computed(() => ({
    ...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
  })),
});

// Dynamic URL with getter
const userId = ref("123");
const { data } = await useFetch(() => `/api/users/${userId.value}`);

// New options (Nuxt 3.14+)
const { data } = await useFetch("/api/data", {
  retry: 3,          // Retry on failure
  retryDelay: 1000,  // Wait between retries
  dedupe: "cancel",  // Cancel previous request
  delay: 300,        // Debounce the request
});

$fetch for Event Handlers

// ONLY use $fetch in event handlers (onClick, onSubmit)
const handleSubmit = async () => {
  const result = await $fetch("/api/users", {
    method: "POST",
    body: { name: "Test" },
  });
};

Auth Check in API

// In server/utils/auth.ts
export async function getAuthenticatedUser(event: H3Event) {
  const session = await getUserSession(event);
  if (!session?.user) {
    throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
  }
  return session.user;
}

// In API handler
export default defineEventHandler(async (event) => {
  const user = await getAuthenticatedUser(event);
  // user is typed and guaranteed to exist
});

SSR-Safe localStorage

// Option 1: import.meta.client guard
watch(preference, (value) => {
  if (import.meta.client) {
    localStorage.setItem("pref", value);
  }
});

// Option 2: onMounted
onMounted(() => {
  const saved = localStorage.getItem("pref");
  if (saved) preference.value = saved;
});

// Option 3: VueUse (SSR-safe)
const theme = useLocalStorage("theme", "light");

Composable vs Util Decision

Needs Nuxt/Vue context (useRuntimeConfig, useRoute, refs)?
├─ YES → COMPOSABLE in /composables/use*.ts
└─ NO → UTIL in /utils/*.ts (client) or /server/utils/*.ts (server)

Key Gotchas

  1. Don't use $fetch at top level - Causes double-fetch (SSR + client). Use useFetch.
  2. Debounce search inputs - Use refDebounced to avoid excessive API calls.
  3. Reset pagination on filter change - Or users see empty page 5 with new filters.
  4. Guard browser APIs - Use import.meta.client, onMounted, or <ClientOnly>.
  5. Nitro tasks are single-instance - Can't run same task twice concurrently. Use DB job queue.
  6. useRouteQuery needs Nuxt composables - Pass route and router explicitly.
  7. Input types aren't auto-generated - Export Zod schemas for client use.
  8. Cookie size limit is 4096 bytes - Store only essential session data.
  9. Ambiguous routes need type assertion - See below.
  10. Never use generic type params with useFetch/$fetch - See below.

Ambiguous Route Type Inference

Nuxt generates types in .nuxt/types/nitro-routes.d.ts with an InternalApi object keyed by route paths. When routes overlap, Nuxt can't infer types from template literals:

// Routes: GET /api/projects and GET /api/projects/:id
// If route.params.id is "", the path matches BOTH routes
const { data } = await useFetch(`/api/projects/${route.params.id}`);
// data type: unknown (ambiguous)

// Fix: Assert the specific route pattern
const { data } = await useFetch(`/api/projects/${route.params.id}` as '/api/projects/:id');
// data type: correctly inferred from /api/projects/:id handler

Extracting Types from useFetch (Never Use Generic Params)

Never pass type parameters to useFetch or $fetch:

// WRONG - Lies to type checker, breaks when endpoint changes
const { data } = await useFetch<Project[]>('/api/projects');

// RIGHT - Let Nuxt infer from the actual endpoint
const { data: projects } = await useFetch('/api/projects');

To use the inferred type elsewhere in your component:

const { data: projects } = await useFetch('/api/projects');

// Get the full ref type (Ref<Project[] | null>)
type ProjectsRef = typeof projects;

// Get a single item type from an array response
type Project = NonNullable<typeof projects.value>[number];

// Use in functions/computeds
function formatProject(project: Project) {
  return `${project.name} - ${project.status}`;
}

const activeProjects = computed(() =>
  projects.value?.filter(p => p.status === 'active') ?? []
);

This ensures your frontend types stay in sync with your API - if the endpoint return type changes, TypeScript will catch mismatches.

Contributing Back

This skill grows by capturing what it missed. If you just worked through something in this domain that this skill did not cover — an error you had to figure out, a behavior that contradicts what is documented above, a workflow knot — ask the user: "Want me to contribute this back to the nuxt-nitro-api skill?"

If yes, run /contribute-skill. If that command is not available, do the equivalent inline: distill the generic lesson (placeholders only — no project names, IDs, domains, or secrets), then branch or fork gallop-systems/agent-skills and open a PR editing this skill.