Documentation
Everything you need to build resource-oriented REST APIs with hydro.
Getting started
hydro is a Nuxt module that generates REST endpoints from resource definitions. Define your schema with Zod, implement reads and writes, and get validation, relationships, OpenAPI docs and error handling for free.
// After adding a resource file, everything is live:GET /api/books?title=Dune&order[title]=asc// 200 OK// X-Total-Count: 1[ { "id": "1", "title": "Dune", "available": true }]// Validation is automatic — no manual checks needed:POST /api/books{ "title": "" }// 422 Unprocessable Entity// Content-Type: application/problem+json{ "type": "https://hydro.dev/errors/validation", "violations": [{ "propertyPath": "/title", "message": "min 1 character" }]}Install
npm install @muffe/hydro zodConfigure
Add hydro to your Nuxt modules and point it at your resources directory. Each file in server/resources/ becomes a set of CRUD routes.
// nuxt.config.tsexport default defineNuxtConfig({ modules: ['@muffe/hydro'], hydro: { prefix: '/api', resourcesDir: 'server/resources', openapi: { enabled: true, info: { title: 'My API', version: '1.0.0' }, }, },})Your first resource
Create server/resources/book.ts with a schema, provider (reads) and processor (writes). That single file generates five REST endpoints.
// server/resources/book.tsimport { defineResource } from '#hydro'import { z } from 'zod'const books = [ { id: '1', title: 'Dune', available: true },]export default defineResource({ name: 'Book', path: 'books', schema: z.object({ id: z.string().optional(), title: z.string().min(1).max(200), available: z.boolean().default(true), }), provider: { async list(ctx) { const { offset, itemsPerPage } = ctx.query.pagination return { items: books.slice(offset, offset + itemsPerPage), total: books.length, } }, async get(ctx) { return books.find(b => b.id === ctx.params.id) ?? null }, }, processor: { async create(ctx) { const book = { ...ctx.input, id: crypto.randomUUID() } books.push(book) return book }, async update(ctx) { const i = books.findIndex(b => b.id === ctx.params.id) const next = { ...books[i], ...ctx.input, id: ctx.params.id } books[i] = next return next }, async delete(ctx) { const i = books.findIndex(b => b.id === ctx.params.id) if (i >= 0) books.splice(i, 1) }, },})Start your dev server and the following routes are live:
// The definition above generates these five endpoints:GET /api/books → List with filters, pagination, sort, sparse fieldsGET /api/books/:id → Fetch a single book by idPOST /api/books → Create (body validated against schema)PATCH /api/books/:id → Partial update (only sent fields are validated)DELETE /api/books/:id → Delete a book by idDefine a resource
Every resource has three building blocks: a schema that describes the data shape, a provider that handles reads, and a processor that handles writes. Hydro wires them together into validated, documented routes.
// A resource definition has three core parts:export default defineResource({ name: 'Book', // PascalCase name for error messages and OpenAPI path: 'books', // kebab-case plural for URL paths schema: z.object({ // Zod schema — validates input and generates docs id: z.string().optional(), title: z.string().min(1), }), // Provider — handle reads (list, get) provider: { async list(ctx) { /* return { items, total } */ }, async get(ctx) { /* return item or null */ }, }, // Processor — handle writes (create, update, delete) processor: { async create(ctx) { /* return created */ }, async update(ctx) { /* return updated */ }, async delete(ctx) { /* no return needed */ }, },})Schema
The schema is a Zod object. Hydro uses it to validate request bodies on create (full schema) and update (partial), coerce defaults, and generate the OpenAPI spec. Every field that appears in your API responses should be declared here.
schema: z.object({ id: z.string().optional(), // usually optional — generated on create title: z.string().min(1).max(200), // validated on every request authorId: z.string().optional(), // FK for relationships available: z.boolean().default(true), // default applied when field is missing}),Provider (reads)
The provider implements list and get. Hydro parses the query string before calling your provider, so you receive structured data on ctx.query — filters, sort rules, pagination and sparse fields are all ready to use.
// Provider handles reads — list and get.// Hydro parses the query string before calling your provider,// so you receive structured data on ctx.query.provider: { async list(ctx) { let items = [...books] // Apply filters from ?title=Dune&available=true if (ctx.query.filters.title) { items = items.filter(b => b.title.includes(String(ctx.query.filters.title)) ) } if (ctx.query.filters.available !== undefined) { items = items.filter(b => String(b.available) === String(ctx.query.filters.available) ) } // Apply sorting from ?order[title]=asc for (const sort of [...ctx.query.sort].reverse()) { items = items.sort((a, b) => { const result = String(a[sort.field]).localeCompare(String(b[sort.field])) return sort.direction === 'asc' ? result : -result }) } // Apply pagination from ?page=2&itemsPerPage=10 const { offset, itemsPerPage } = ctx.query.pagination const page = items.slice(offset, offset + itemsPerPage) // Apply sparse fields from ?fields=id,title return { items: page.map(item => selectFields(item, ctx.query.fields)), total: items.length, } }, async get(ctx) { // ctx.params.id comes from the route /api/books/:id return books.find(b => b.id === ctx.params.id) ?? null },}Processor (writes)
The processor implements create, update and delete. The request body has already been validated and coerced by the time your processor runs. On update, only the fields the client actually sent are present (partial).
// Processor handles writes — create, update, delete.// ctx.input is already validated and coerced by the schema.processor: { async create(ctx) { // ctx.input contains the validated request body const book = { available: true, // defaults from schema are applied ...ctx.input, id: crypto.randomUUID(), } books.push(book) return book // returned value is sent as JSON response }, async update(ctx) { // ctx.input is a partial — only fields the client sent // missing fields keep their current value const i = books.findIndex(b => b.id === ctx.params.id) const next = { ...books[i], ...ctx.input, id: ctx.params.id } books[i] = next return next }, async delete(ctx) { // No return value needed — Hydro responds with 204 const i = books.findIndex(b => b.id === ctx.params.id) if (i >= 0) books.splice(i, 1) },}Context object
Every provider and processor function receives a typed context with the request data you need:
// Your provider and processor use ctx to access everythingprovider: { async list(ctx) { const { filters, sort, pagination, fields } = ctx.query // Use pagination directly — values are already clamped const items = await db .query('SELECT * FROM books LIMIT $1 OFFSET $2', [ pagination.itemsPerPage, pagination.offset, ]) return { items, total: count } },},processor: { async create(ctx) { // ctx.input is validated and coerced — safe to insert const result = await db.query( 'INSERT INTO books (title, available) VALUES ($1, $2) RETURNING *', [ctx.input.title, ctx.input.available], ) return result.rows[0] },},Here's what's available on the context object:
ctx.paramsRoute parameters. On item routes, contains { id } from /books/:id.ctx.query.filtersParsed filter values. Only fields listed in the resource filters array.ctx.query.pagination{ page, itemsPerPage, offset } — clamped by resource pagination config.ctx.query.sort[{ field, direction }] — parsed from order[field]=asc query params.ctx.query.fieldsstring[] of requested sparse fields, or undefined if not set.ctx.inputValidated request body. Full schema for create, partial for update.ctx.authAuth info extracted by the configured auth provider, if enabled.ctx.requestRaw H3 request event with method, url, headers.Query parameters
Hydro auto-parses the query string into structured data on ctx.query. Your provider receives pre-parsed filters, sort rules, pagination offsets and sparse field selections — no manual query string parsing needed.
Declaring filters
Only fields listed in the resource's filters array are parsed from the query string. Unknown parameters are silently ignored.
// Only fields listed in the filters array are parsed from the query string.// Unknown filter parameters are silently ignored.export default defineResource({ name: 'Book', path: 'books', schema: z.object({ id: z.string().optional(), title: z.string().min(1), authorId: z.string().optional(), available: z.boolean().default(true), }), filters: ['title', 'authorId', 'available'], // ?title=Dune → parsed, available in ctx.query.filters.title // ?authorId=a1 → parsed, available in ctx.query.filters.authorId // ?unknown=foo → ignored, not in the filters array})Query parameters reference
filters array are accepted.asc or desc. Can be repeated for multi-field sort.Parsed query shape
Hydro parses the raw query string into a structured object on ctx.query. Here's what the parsed data looks like for a typical request:
// Given this request:// GET /api/books?title=Dune&order[title]=asc&page=2&itemsPerPage=10&fields=id,title// ctx.query contains:ctx.query = { filters: { title: 'Dune', // string value from the query string }, sort: [ { field: 'title', direction: 'asc' }, // parsed from order[title]=asc ], pagination: { page: 2, // current page number itemsPerPage: 10, // clamped by resource pagination config offset: 10, // pre-computed: (page - 1) * itemsPerPage }, fields: ['id', 'title'], // sparse fieldset}Usage examples
// All query parameters can be combined freely// Filter by fields (must be listed in resource `filters` array)GET /api/books?title=DuneGET /api/books?available=true// Sort by fieldGET /api/books?order[title]=ascGET /api/books?order[title]=desc// PaginateGET /api/books?page=2&itemsPerPage=25// Sparse fieldset — only return listed fieldsGET /api/books?fields=id,title// Combine everythingGET /api/books?title=Dune&order[title]=asc&page=1&itemsPerPage=10&fields=id,title// Response headers include pagination metadata:// X-Total-Count: 3// X-Has-More: truePagination config
Set per-resource limits to prevent clients from requesting too many records. When a client sends ?itemsPerPage=999, Hydro clamps it to your max.
// Per-resource pagination limits (optional)// Clamps client values so ?itemsPerPage=999 respects your capexport default defineResource({ // ... pagination: { default: 10, max: 50 },})Relationships
Hydro resolves relationships between resources. When a client sends related data, Hydro validates references, creates nested records, and writes foreign keys — all before your processor runs. You declare the relationship once, and Hydro handles the wiring.
// Relationships live inside the resource definition,// next to schema, filters, provider and processor.export default defineResource({ name: 'Book', path: 'books', schema: z.object({ id: z.string().optional(), title: z.string().min(1), authorId: z.string().optional(), }), filters: ['title', 'authorId'], relationships: { // ← declare relationships here author: { kind: 'belongsTo', target: 'Author', foreignKey: 'authorId', writable: { create: 'reference', update: 'reference' }, }, }, provider: { /* ... */ }, processor: { /* ... */ },})belongsTo (many-to-one)
Use belongsTo on the side that holds the foreign key. When a client sends a reference like "author": "/authors/a1", Hydro looks up the target resource, verifies it exists, and replaces the reference with the foreign key value in ctx.input. Your processor never sees the relationship field — it just receives the flat FK.
// Book has one Author — declare belongsTo on the "many" side// server/resources/book.tsexport default defineResource({ name: 'Book', path: 'books', schema: z.object({ id: z.string().optional(), title: z.string().min(1).max(200), authorId: z.string().optional(), }), relationships: { author: { kind: 'belongsTo', target: 'Author', // references the Author resource by name foreignKey: 'authorId', // field that stores the FK on Book writable: { create: 'reference', // accept "/authors/a1" or plain "a1" during POST update: 'reference', // same for PATCH }, }, }, // ...provider and processor})// Client sends a reference — Hydro resolves it to authorIdPOST /api/booksContent-Type: application/json{ "title": "The Fifth Season", "author": "/authors/a5"}// What happens internally:// 1. Hydro sees "author" matches a relationship field// 2. It strips the path prefix and looks up Author with id "a5"// 3. It calls Author.provider.get to verify the author exists// 4. It replaces "author" with authorId: "a5" in ctx.input// 5. Your processor receives { title: "The Fifth Season", authorId: "a5" }hasMany (one-to-many)
Use hasMany on the side that owns the relationship. When a client sends nested objects, Hydro validates each one against the target resource's schema, then calls the target's processor.create after your processor creates the parent. Hydro attaches the parent's id as the foreign key on each child.
// Author has many Books — declare hasMany on the "one" side// server/resources/author.tsexport default defineResource({ name: 'Author', path: 'authors', schema: z.object({ id: z.string().optional(), name: z.string().min(1).max(120), country: z.string().length(2), books: z.array(z.object({ // accept inline book objects title: z.string().min(1), available: z.boolean().default(true), })).optional(), }), relationships: { books: { kind: 'hasMany', target: 'Book', // references the Book resource by name foreignKey: 'authorId', // field on Book that points back to Author writable: { create: 'nested', // accept inline objects during POST update: false, // books can't be changed through author PATCH }, }, }, // ...provider and processor})// Client sends nested objects — Hydro creates them after the parentPOST /api/authorsContent-Type: application/json{ "name": "Martha Wells", "country": "US", "books": [ { "title": "All Systems Red" }, { "title": "Artificial Condition" } ]}// What happens internally:// 1. Hydro validates the author fields against the schema// 2. Your processor.create runs — the author gets an id (e.g. "a6")// 3. Hydro validates each nested book against Book's schema// 4. For each book, it calls Book.processor.create with:// { title: "All Systems Red", authorId: "a6" }// 5. The response includes the author (not the nested books)// Response:201 Created{ "id": "a6", "name": "Martha Wells", "country": "US"}Writable modes
Each relationship configures how it can be written during create and update. The mode determines what clients can send and what Hydro does with it:
// Each relationship configures writability per operationrelationships: { author: { kind: 'belongsTo', target: 'Author', foreignKey: 'authorId', writable: { create: 'reference', // what happens on POST /books update: 'reference', // what happens on PATCH /books/:id }, },}// The writable mode controls what the client can send:// 'reference' → accept a string (FK id or "/authors/a1" path)// { "author": "/authors/a1" }// Hydro resolves the reference and sets authorId in ctx.input// 'nested' → accept inline objects, creates them after the parent// { "books": [{ "title": "Dune" }] }// Hydro validates each against Book's schema, then calls Book.processor.create// 'either' → accept both references and nested objects// { "author": "/authors/a1" } ← reference// { "author": { "name": "..." } } ← nested (Hydro creates it)// false → block writes through this relationship entirely// Sending the field returns a 422 validation errorCustom operations
Not everything fits into CRUD. Custom operations let you add named actions to a resource — like checking out a book or featuring an author. They get their own route, validate input and output against schemas, and appear in the generated OpenAPI docs.
// Custom operations become real routes with full validation.// They appear alongside CRUD routes in the OpenAPI spec.export default defineResource({ name: 'Book', path: 'books', // ...schema, provider, processor customOperations: { checkout: { // the key becomes the path segment method: 'POST', scope: 'item', path: 'checkout', input: z.object({ userId: z.string() }), output: z.object({ available: z.boolean() }), async handler(ctx) { /* ... */ }, }, },})// Generates: POST /api/books/:id/checkout// Input body validated against the input schema// Response validated against the output schemaItem-scoped operations
Item operations bind to a specific resource instance. The URL includes the resource id, and the handler receives ctx.params.id. Use them for actions that modify or query a single record.
// Custom operations handle anything beyond plain CRUD// Declare them inside the resource definitionexport default defineResource({ name: 'Book', path: 'books', // ...schema, provider, processor customOperations: { // An item-scoped operation — acts on a single book checkout: { name: 'checkout', method: 'POST', scope: 'item', // binds to /api/books/:id/checkout path: 'checkout', input: z.object({ userId: z.string().min(1), }), output: z.object({ id: z.string().optional(), available: z.boolean(), }), async handler(ctx) { // ctx.input — validated against input schema // ctx.params.id — the book id from the URL const book = books.find(b => b.id === ctx.params.id)! book.available = false return { id: book.id, available: book.available } }, }, },})Collection-scoped operations
Collection operations bind to the resource as a whole. There's no id in the URL. Use them for queries or batch actions that span multiple records.
// Collection-scoped operations — no :id in the URLcustomOperations: { spotlight: { name: 'spotlight', method: 'POST', scope: 'collection', // binds to /api/authors/spotlight path: 'spotlight', input: z.object({ country: z.string().length(2).optional(), }).optional(), // input can be optional entirely output: z.array(z.object({ id: z.string().optional(), name: z.string(), bookCount: z.number(), })), async handler(ctx) { // ctx.input may be undefined if body was omitted const country = ctx.input?.country?.toUpperCase() return authors .filter(a => a.featured && (!country || a.country === country)) .map(a => ({ id: a.id, name: a.name, bookCount: bookCount(a.id) })) }, },},Calling custom operations
// Calling the checkout operationPOST /api/books/1/checkoutContent-Type: application/json{ "userId": "demo-user" }// Response:200 OK{ "id": "1", "available": false }// Calling the spotlight operationPOST /api/authors/spotlightContent-Type: application/json{ "country": "US" }// Response:200 OK[ { "id": "a1", "name": "Frank Herbert", "bookCount": 1 }, { "id": "a3", "name": "Ursula K. Le Guin", "bookCount": 2 }, { "id": "a4", "name": "Octavia E. Butler", "bookCount": 2 }]Loading the entity automatically
For item-scoped operations, you can set loadEntity: true to have Hydro call provider.get before your handler runs. The loaded entity is available on ctx.entity. If the entity doesn't exist, Hydro returns 404 automatically.
// For item-scoped ops, you can ask Hydro to load the entity firstcheckout: { name: 'checkout', method: 'POST', scope: 'item', path: 'checkout', loadEntity: true, // Hydro calls provider.get for you input: z.object({ userId: z.string() }), output: z.object({ id: z.string().optional(), available: z.boolean() }), async handler(ctx) { // ctx.entity is now available — the loaded book // Still null-safe: returns 404 if provider.get returns null ctx.entity.available = false return { id: ctx.entity.id, available: false } },},scope: 'item'Creates /api/books/:id/checkout. Handler receives ctx.params.id.scope: 'collection'Creates /api/authors/spotlight. No id in URL or params.Error handling
Hydro uses RFC 7807 Problem Details for all error responses. Validation errors are generated automatically from your schema — every violation includes a JSON pointer to the failing field. The format is stable and machine-readable, making it easy to map errors to form inputs on the client side.
// Hydro catches validation errors automatically —// no try/catch needed in your handlers:POST /api/books{ "title": "", "available": "not-a-bool" }→ 422 Unprocessable EntityContent-Type: application/problem+json{ "type": "https://hydro.dev/errors/validation", "title": "Validation Failed", "status": 422, "violations": [ { "propertyPath": "/title", "message": "min 1 character" }, { "propertyPath": "/available", "message": "Expected boolean" } ]}Validation errors
When a request body fails schema validation, Hydro responds with a 422 and a list of violations. Each violation has a propertyPath (a JSON pointer like /name) and a human-readable message.
// All validation errors follow RFC 7807 Problem Details// Content-Type: application/problem+json// Example: missing required fieldsPOST /api/authors{ "name": "", "country": "USA" }→ 422 Unprocessable Entity{ "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" } ]}Error types
Hydro provides typed error classes you can throw from any provider, processor, or custom operation handler. They all produce Problem Details responses. Hydro throws ValidationError and NotFoundError automatically, but you can use the full set for your own logic.
import { HydroError, ValidationError, NotFoundError, ConflictError, ForbiddenError, UnauthorizedError }from '@muffe/hydro/core'// Hydro throws these automatically, but you can throw them// from your own provider/processor handlers too:// Validation — 422// Thrown when schema validation fails.// Hydro generates violations from the schema automatically.throw new ValidationError([ { propertyPath: '/email', message: 'Already taken' },])// Not Found — 404// Thrown when provider.get returns null, or resource doesn't exist.// Hydro throws this automatically for missing items.throw new NotFoundError('Book not found')// Conflict — 409// Use for duplicate checks or optimistic locking failures.throw new ConflictError('Email already registered')// Forbidden — 403// Use when the user is authenticated but lacks permission.throw new ForbiddenError('Not the resource owner')// Unauthorized — 401// Use when no authentication is provided.throw new UnauthorizedError('Login required')// Custom errors — any statusthrow new HydroError(429, 'Too Many Requests', 'Try again in 60 seconds')Response shape
Every error response follows the same structure. The violations array is only present on validation errors (422). Other errors include a detail string instead.
// Every error response follows this shape:interface ProblemDetails { type: string // Machine-readable error identifier title: string // Human-readable summary status: number // HTTP status code detail?: string // Specific explanation instance?: string // The request URL that caused the error violations?: Array<{ // Present for validation errors (422) propertyPath: string // JSON pointer to the field (e.g. "/name") message: string // Human-readable error message }>}// Response headers:// Content-Type: application/problem+jsonOpenAPI & generated docs
Hydro generates a full OpenAPI 3.1 spec from your resource definitions at runtime. Your schemas become request/response bodies, your filters become query parameters, your custom operations become dedicated paths. No extra config files or annotations needed. The spec feeds an interactive Scalar docs UI so consumers can explore your API immediately.
What gets generated
// Enable OpenAPI in nuxt.config.tshydro: { prefix: '/api', openapi: { enabled: true, info: { title: 'Library API', version: '1.0.0' }, },}// Hydro generates the spec from your resource definitions at runtime:// 1. Schema → request/response bodies with types and constraints// 2. Filters → query parameters// 3. Pagination → page and itemsPerPage parameters// 4. Relationships → referenced schemas// 5. Custom operations → dedicated paths with input/output schemas// 6. Auth rules → security requirements on endpointsGenerated spec example
The output is a standard OpenAPI 3.1 document you can feed to any tooling. Here's a simplified look at what Hydro produces:
// GET /api/_openapi.json produces something like:{ "openapi": "3.1.0", "info": { "title": "Library API", "version": "1.0.0" }, "paths": { "/api/books": { "get": { "summary": "List Books", "parameters": [ { "name": "title", "in": "query", "schema": { "type": "string" } }, { "name": "order[title]", "in": "query", "schema": { "type": "string" } }, { "name": "page", "in": "query", "schema": { "type": "integer" } }, { "name": "itemsPerPage", "in": "query", "schema": { "type": "integer" } }, { "name": "fields", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "List of books", "content": { "application/json": { /* item schema */ } } } } }, "post": { "summary": "Create Book", "requestBody": { /* schema from zod */ }, "responses": { "201": { /* ... */ } } } }, "/api/books/{id}": { "get": {}, "patch": {}, "delete": {} }, "/api/books/{id}/checkout": { "post": { "summary": "Checkout", /* input/output schemas */ } } }}Endpoints
// Generated routesGET /api/_openapi.json → Full OpenAPI 3.1 JSON specGET /api/_docs → Scalar interactive docs UI// Try it live in this playground:// https://hydro-playground.vercel.app/api/_docs// Disable the docs UI but keep the spec:openapi: { docsPath: undefined }// Disable everything:openapi: { enabled: false }AI context
hydro ships a compact AI context pack for coding tools. Use it when you want an AI agent to add resources, relationships or custom operations in a different Nuxt codebase without guessing the module conventions.
Complete hydro usage guide at /llms-full.txt
Compact summary with canonical links at /llms.txt
Copy-paste prompt
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.txtBefore 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.