Use this file as the first context block when asking an AI coding tool to add or modify hydro resources in a Nuxt codebase.
hydro creates resource-oriented REST APIs for Nuxt and Nitro. A resource definition generates CRUD routes, request validation, query parsing, relationship writes, custom operations, RFC 7807 errors, and OpenAPI docs.
npm install @muffe/hydro zod// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@muffe/hydro'],
hydro: {
prefix: '/api',
resourcesDir: 'server/resources',
openapi: {
enabled: true,
info: { title: 'My API', version: '1.0.0' },
},
},
})Put one resource per file in server/resources/. Export defineResource() as the default export.
// server/resources/book.ts
import { defineResource } from '#hydro'
import { z } from 'zod'
interface Book {
id?: string
title: string
authorId?: string
available: boolean
}
const books: Book[] = []
export default defineResource<Book>({
name: 'Book',
path: 'books',
schema: z.object({
id: z.string().optional(),
title: z.string().min(1).max(200),
authorId: z.string().optional(),
available: z.boolean().default(true),
}),
filters: ['title', 'authorId', 'available'],
pagination: { default: 10, max: 50 },
provider: {
async list(ctx) {
let items = [...books]
if (ctx.query.filters.title) {
items = items.filter(book => book.title.includes(String(ctx.query.filters.title)))
}
if (ctx.query.filters.authorId) {
items = items.filter(book => book.authorId === String(ctx.query.filters.authorId))
}
if (ctx.query.filters.available !== undefined) {
items = items.filter(book => String(book.available) === String(ctx.query.filters.available))
}
for (const sort of [...ctx.query.sort].reverse()) {
items = items.sort((left, right) => {
const leftValue = String(left[sort.field as keyof Book] ?? '')
const rightValue = String(right[sort.field as keyof Book] ?? '')
const result = leftValue.localeCompare(rightValue)
return sort.direction === 'asc' ? result : -result
})
}
const { offset, itemsPerPage } = ctx.query.pagination
const page = items.slice(offset, offset + itemsPerPage)
return {
items: selectFields(page, ctx.query.fields),
total: items.length,
}
},
async get(ctx) {
return books.find(book => book.id === ctx.params.id) ?? null
},
},
processor: {
async create(ctx) {
const book = { available: true, ...ctx.input, id: crypto.randomUUID() } as Book
books.push(book)
return book
},
async update(ctx) {
const index = books.findIndex(book => book.id === ctx.params.id)
const next = { ...books[index], ...ctx.input, id: ctx.params.id } as Book
books[index] = next
return next
},
async delete(ctx) {
const index = books.findIndex(book => book.id === ctx.params.id)
if (index >= 0) books.splice(index, 1)
},
},
})
function selectFields<T extends object>(items: T[], fields?: string[]): Array<T | Partial<T>> {
if (!fields?.length) return items
return items.map((item) => {
const selected: Partial<T> = {}
for (const field of fields) {
if (field in item) selected[field as keyof T] = item[field as keyof T]
}
return selected
})
}For path: 'books' and prefix: '/api', hydro generates:
GET /api/books
GET /api/books/:id
POST /api/books
PATCH /api/books/:id
DELETE /api/books/:idGET /api/books returns the list body as JSON and includes pagination metadata in headers when provided by the provider:
X-Total-Count: 42
X-Has-More: trueProvider handles reads:
provider: {
async list(ctx) {
return { items, total }
},
async get(ctx) {
return item ?? null
},
}Processor handles writes:
processor: {
async create(ctx) {
return created
},
async update(ctx) {
return updated
},
async delete(ctx) {
// No return needed. Hydro responds with 204.
},
}All handlers receive ctx.
ctx.params.id // Route id from /books/:id
ctx.query.filters // Parsed allow-listed filters
ctx.query.sort // [{ field, direction }]
ctx.query.pagination // { page, itemsPerPage, offset }
ctx.query.fields // Sparse fieldset string[] or undefined
ctx.input // Validated request body for writes and custom ops
ctx.auth // Auth info when auth is enabled
ctx.request // Request info with method, url, headers, rawExample usage:
async list(ctx) {
const { filters, sort, pagination, fields } = ctx.query
const rows = await db.books.findMany({
where: { title: filters.title ? String(filters.title) : undefined },
skip: pagination.offset,
take: pagination.itemsPerPage,
})
return { items: applyFields(rows, fields), total: await db.books.count() }
}
async create(ctx) {
return db.books.create({ data: ctx.input })
}Declare filterable fields on the resource:
export default defineResource({
// ...
filters: ['title', 'authorId', 'available'],
})Supported query parameters:
GET /api/books?title=Dune
GET /api/books?authorId=a1&available=true
GET /api/books?order[title]=asc
GET /api/books?page=2&itemsPerPage=25
GET /api/books?fields=id,title,available
GET /api/books?title=Dune&order[title]=asc&page=1&itemsPerPage=10&fields=id,titleParsed shape:
// GET /api/books?title=Dune&order[title]=asc&page=2&itemsPerPage=10&fields=id,title
ctx.query = {
filters: { title: 'Dune' },
sort: [{ field: 'title', direction: 'asc' }],
pagination: { page: 2, itemsPerPage: 10, offset: 10 },
fields: ['id', 'title'],
}Relationships live in the resource definition.
Use belongsTo on the resource that stores the foreign key.
// server/resources/book.ts
export default defineResource({
name: 'Book',
path: 'books',
schema: z.object({
id: z.string().optional(),
title: z.string().min(1),
authorId: z.string().optional(),
}),
relationships: {
author: {
kind: 'belongsTo',
target: 'Author',
foreignKey: 'authorId',
writable: { create: 'reference', update: 'reference' },
},
},
})Client request:
POST /api/books
Content-Type: application/json
{
"title": "The Fifth Season",
"author": "/authors/a5"
}Hydro verifies Author a5 exists, removes author, and gives the processor ctx.input with authorId: 'a5'.
Use hasMany on the parent resource.
// server/resources/author.ts
export default defineResource({
name: 'Author',
path: 'authors',
schema: z.object({
id: z.string().optional(),
name: z.string().min(1),
country: z.string().length(2),
books: z.array(z.object({ title: z.string().min(1) })).optional(),
}),
relationships: {
books: {
kind: 'hasMany',
target: 'Book',
foreignKey: 'authorId',
writable: { create: 'nested', update: false },
},
},
})Client request:
POST /api/authors
Content-Type: application/json
{
"name": "Martha Wells",
"country": "US",
"books": [{ "title": "All Systems Red" }]
}Hydro creates the Author first, then validates and creates each nested Book with the new authorId.
Writable modes:
writable: { create: 'reference', update: 'reference' } // Accept string id or path
writable: { create: 'nested', update: false } // Accept inline objects on create
writable: { create: 'either', update: 'either' } // Accept references or inline objects
writable: { create: false, update: false } // Field is not writableUse custom operations for behavior that is not plain CRUD. They validate input and output and appear in OpenAPI.
customOperations: {
checkout: {
name: 'checkout',
method: 'POST',
scope: 'item',
path: 'checkout',
input: z.object({ userId: z.string().min(1) }),
output: z.object({ id: z.string().optional(), available: z.boolean() }),
async handler(ctx) {
const book = books.find(book => book.id === ctx.params.id)!
book.available = false
return { id: book.id, available: book.available }
},
},
}Generated route:
POST /api/books/:id/checkoutcustomOperations: {
spotlight: {
name: 'spotlight',
method: 'POST',
scope: 'collection',
path: 'spotlight',
input: z.object({ country: z.string().length(2).optional() }).optional(),
output: z.array(z.object({
id: z.string().optional(),
name: z.string(),
bookCount: z.number(),
})),
async handler(ctx) {
const country = ctx.input?.country?.toUpperCase()
return authors
.filter(author => author.featured && (!country || author.country === country))
.map(author => ({ id: author.id, name: author.name, bookCount: authorBookCount(author.id) }))
},
},
}Generated route:
POST /api/authors/spotlightFor item operations, loadEntity: true asks Hydro to call provider.get before the handler and provide ctx.entity. Missing entities return 404 automatically.
checkout: {
name: 'checkout',
method: 'POST',
scope: 'item',
path: 'checkout',
loadEntity: true,
async handler(ctx) {
return ctx.entity
},
}Validation errors return RFC 7807 Problem Details as application/problem+json.
POST /api/authors
Content-Type: application/json
{ "name": "", "country": "USA" }
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://hydro.dev/errors/validation",
"title": "Validation Failed",
"status": 422,
"detail": "One or more fields failed validation",
"violations": [
{ "propertyPath": "/name", "message": "Required" },
{ "propertyPath": "/country", "message": "Expected 2 characters" }
]
}Core error classes are exported from @muffe/hydro/core:
import {
ConflictError,
ForbiddenError,
HydroError,
NotFoundError,
UnauthorizedError,
ValidationError,
} from '@muffe/hydro/core'
throw new ValidationError([{ propertyPath: '/email', message: 'Already taken' }])
throw new NotFoundError('Book not found')
throw new ConflictError('Duplicate slug')
throw new ForbiddenError('Not the owner')
throw new UnauthorizedError('Login required')
throw new HydroError(429, 'Too Many Requests', 'Try again later')Module config:
export default defineNuxtConfig({
hydro: {
auth: {
enabled: true,
provider: 'nuxt-auth-utils',
defaultAccess: { development: 'public', production: 'deny' },
session: { userId: 'user.id', roles: 'user.roles' },
policies: {
ownsResource({ auth, entity }) {
return auth?.userId === entity?.ownerId
},
},
},
},
})Resource access rules:
export default defineResource({
// ...
auth: {
default: 'authenticated',
operations: {
list: 'public',
create: { roles: ['editor'] },
update: { policy: 'ownsResource', needsEntity: true },
},
},
})Access values:
'public'
'authenticated'
'deny'
{ roles: ['editor'] }
{ permissions: ['books:update'] }
{ policy: 'ownsResource', needsEntity: true }When enabled, Hydro generates:
GET /api/_openapi.json
GET /api/_docsDisable:
hydro: {
openapi: { enabled: false },
}When modifying a foreign Nuxt codebase that uses hydro, follow these rules:
- Look for
hydroconfig innuxt.config.tsto confirm prefix and resources directory. - Put resource files in the configured resources directory, usually
server/resources/. - Import
defineResourcefrom#hydroinside Nuxt resource files. - Use Zod schemas unless the project already uses another Standard Schema library.
- Keep reads in
providerand writes inprocessor. - Use
ctx.queryinstead of manually parsing query strings. - Use
ctx.inputas trusted validated input, but still handle business constraints. - Return
nullfromprovider.getfor missing records. Hydro maps this to 404. - Return
{ items, total }fromprovider.listwhen possible. - Use relationships for reference or nested writes instead of manually accepting both shapes in processors.
- Use custom operations for actions that are not CRUD.
- Use RFC 7807 error classes from
@muffe/hydro/corefor business errors. - Keep resource
namePascalCase andpathplural kebab-case. - Do not generate manual Nitro route files for CRUD endpoints that Hydro already generates.
I am working in a Nuxt codebase that uses hydro.
Use this context as the source of truth: https://hydro-playground.vercel.app/llms-full.txt
Before coding:
- Read nuxt.config.ts and confirm hydro.prefix and hydro.resourcesDir.
- Inspect existing files in the resources directory and match their style.
- Add or edit one resource per file.
- Import defineResource from #hydro.
- Use Zod schemas.
- Keep reads in provider and writes in processor.
- Use ctx.query for filters, sort, pagination and sparse fields.
- Use ctx.input for validated request bodies.
- Use relationships and customOperations when appropriate.
- Verify with typecheck and relevant tests.