Controller & Procedure
A controller is a class that groups endpoints as static members. initSegment initializes the class (it doesn’t create an instance) in a segment route, and that makes its members HTTP endpoints.
A procedure is one such member: one endpoint, usually decorated with an HTTP method such as @get(), @post(), @put(), @patch() or @del(). You can write it in two styles, and both compile to Next.js Route Handlers:
- Bare static method: takes
(req: NextRequest, params), as a plain Route Handler does. It still lives in a class, with a generated, typed RPC client and segments. procedure()wrapper: turns the static method into a typed function that validates withbody,query,paramsandoutputschemas. It adds.fn()local calls (SSR, server actions, AI tool execution), OpenAPI generation and AI tool exposure.
Use the bare style for simple cases, and procedure() when you want validation or a function you can call without HTTP.
import { put, prefix, type VovkRequest } from 'vovk';
@prefix('users') // optional prefix for all routes in this controller
export default class UserController {
@put('{id}')
static async updateUser(
req: VovkRequest<{ email: string }, { notify: 'email' | 'push' | 'none' }>,
{ id }: { id: string }
) {
const data = await req.json();
// ...
}
}To initialize the class, add it to the controllers object of initSegment in a segment route:
import { initSegment } from 'vovk';
import UserController from '../../../modules/user/user-controller';
const controllers = {
UserRPC: UserController,
};
export type Controllers = typeof controllers;
export const { GET, POST, PATCH, PUT, HEAD, OPTIONS, DELETE } = initSegment({ controllers });The object key is the name of the RPC module on the client:
import { UserRPC } from '@/client';
// sends PUT /api/users/69?notify=push
const updatedUser = await UserRPC.updateUser({
params: { id: '69' },
query: { notify: 'push' },
body: userData,
});The RPC method takes body and query because the request is typed with VovkRequest; with NextRequest it takes params only.
See TypeScript Client.
For the root endpoint of a segment, use no prefix and no path (or an empty string) in the HTTP decorator.
import { get } from 'vovk';
import type { NextRequest } from 'next/server';
export default class UserController {
@get()
static async listUsers(req: NextRequest) {
// ...
}
}Auto-Generated Endpoints
Every HTTP decorator has an .auto method that makes the endpoint path from the method name, so the handler reads more like RPC.
import { prefix, put } from 'vovk';
@prefix('users')
export default class UserController {
// creates PUT /api/users/do-something
@put.auto()
static async doSomething(/* ... */) {
// ...
}
}With a params schema, .auto() also adds each param to the path: getUser with { id } becomes get-user/{id}.
Request Headers
A procedure can use any Next.js API, such as cookies and headers, from the next package. See the Next.js documentation .
import { put, prefix } from 'vovk';
import type { NextRequest } from 'next/server';
import { cookies, headers } from 'next/headers';
@prefix('users')
export default class UserController {
@put('{id}')
static async updateUser(req: NextRequest, { id }: { id: string }) {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('sessionToken');
const headersList = await headers();
const userAgent = headersList.get('user-agent');
// ...
}
}Or read req.headers from the Web Request API : req.headers.get('user-agent').
VovkRequest Type
VovkRequest mirrors the NextRequest type and adds type parameters for the request body (the json method) and the query (the searchParams property). With them, the procedure reads these parts of the request with types.
import { put, prefix, type VovkRequest } from 'vovk';
import type { User } from '../../types';
@prefix('users')
export default class UserController {
// Example request: PUT /api/users/69?notify=push
@put('{id}')
static async updateUser(
req: VovkRequest<Partial<User>, { notify: 'email' | 'push' | 'none' }>,
{ id }: { id: string }
) {
const data = await req.json(); // Partial<User>
const notify = req.nextUrl.searchParams.get('notify'); // 'email' | 'push' | 'none'
// ...
return updatedUser;
}
}VovkRequest extends Request, not NextRequest, so the vovk package doesn’t depend on the next package. It copies the documented NextRequest properties: cookies (with the get, getAll, set, delete, has and clear methods) and nextUrl (with basePath, buildId, pathname, search and a typed searchParams).
searchParams reads the raw URL, so its types follow what the URL holds. A required string field keeps its type, and any other field is a string. get may return null for an optional field, an array or an object (the client sends an array as tags[0]=a, under keys of its own). The parsed query comes from req.vovk.query().
procedure Function
The procedure function turns a static method into a typed, validated function. It takes schemas for the body, query, params and output from any library that implements both Standard Schema and Standard JSON Schema , such as Zod , Valibot or Arktype . A schema that implements only Standard Schema, as Zod before 4.2 does, still validates, but procedure() warns and emits its JSON Schema as {}.
With these three libraries, a type that JSON Schema can’t describe, such as a Date or a bigint, is emitted as {} (any value). The server still validates it, but client-side validation and the Python and Rust clients accept any value there.
procedure() returns an object with a .handle() method, which takes the handler. The handler gets the request typed as VovkRequest<TBody, TQuery, TParams>, and the validated params: TParams as its second argument. Without a params schema, the second argument and req.vovk.params() hold the route params as strings, typed Record<string, string>.
import { procedure, prefix, put } from 'vovk';
import { z } from 'zod';
@prefix('users')
export default class UserController {
@put('{id}')
static updateUser = procedure({
params: z.object({ id: z.uuid() }),
body: z.object({ email: z.email() }),
query: z.object({ notify: z.enum(['email', 'push', 'none']) }),
output: z.object({ success: z.boolean() }),
}).handle(async (req, { id }) => {
const { email } = await req.vovk.body();
const { notify } = req.vovk.query();
// ...
});
}req.json() and req.nextUrl.searchParams work over HTTP only. A .fn() call and a derived AI tool give the handler only req.vovk, so a procedure that is called both ways reads its body and query with req.vovk.body() and req.vovk.query().
Without .handle(), the procedure throws Not Implemented (501) when called.
To keep business logic in its own layer, see Services.
HTTP decorator is optional
A procedure made with procedure() works without an HTTP decorator. You call it with .fn() in SSR, server components, server actions, AI tool execution and so on. The decorator also serves the procedure as an HTTP endpoint and adds it to the generated RPC client. See Calling Procedures Locally for the full reference, including how to attach a standalone procedure to a controller later.
procedure Options
body, query, and params
body, query and params take the input schemas. They validate the request data before the handler runs.
A request without a body is validated as undefined, so an optional schema such as z.object({ ... }).optional() lets the client leave the body out. So is an empty body with a JSON content type. An empty text or an empty file is a body.
Data that fails validation gets a 400 response. Its message and its cause.issues hold the first 20 issues, and the message says how many more there are. An issue in the response keeps its message, its path as a list of keys, and the library’s other fields that hold text, a number or a boolean, such as Zod’s code. Fields that copy the failed value are left out. The error that the segment’s onError gets is the server’s: its cause.issues holds every issue as the library made it, the failed values included.
output and iteration
output and iteration take the output schemas: output for JSON responses, iteration for JSON Lines. Both are optional. When set, the RPC method’s result type follows output, and the type of its stream items follows iteration, instead of the handler’s return type. OpenAPI, AI tools, and the Python, Rust and future clients use them too. Client-side validation doesn’t. A procedure takes one or the other: both together don’t compile, and procedure() throws when it gets them.
A response that fails output or iteration validation is a bug in the handler, not in the request. The error is a plain Error without a status code. In production, the client gets 500 “Internal server error”, and the validation issues stay on the server: the segment’s onError gets them as the library made them, in the error’s cause.issues. In development, the message names the failing fields.
contentType
contentType sets the Content-Type the request body must have, as a string or an array of strings. It affects:
- Client-side
bodytype: the RPC method’sbodytype follows the content type, such asFormDatafor form data,stringfor text types andFile | ArrayBuffer | Uint8Array | Blobfor binary types. - Server-side body parsing:
req.vovk.body()parses the body by the request’sContent-Typeheader, into parsed JSON, a parsedFormData, astringor aFile. - 415 check: when
contentTypeis set, a request without a matchingContent-Typeheader gets a415 Unsupported Media Typeerror. The header must name one media type, compared case-insensitively: a comma-separated list is refused. A request without a body skips the check. - Wildcards: patterns such as
'video/*','image/*'or'*/*'accept a range of media types.
See Content Type for every supported type, how the body is parsed, and examples.
disableServerSideValidation
Turns off server-side validation: true for all of it, or an array of validation types (body, query, params, output, iteration). It doesn’t change the RPC types or client-side validation.
skipSchemaEmission
Leaves the handler’s JSON Schema out of the emitted schema: true for all of it, or an array of validation types (body, query, params, output, iteration). It doesn’t change the RPC types, but it turns off the features that read the emitted schema, client-side validation included. The declared contentType is still emitted, since the clients encode the body by it.
validateEachIteration
Applies to iteration only. Set it to validate every streamed item. By default, only the first item is validated.
operationObject
Sets the OpenAPI operation details where the @operation decorator can’t go, such as with fn on a regular function instead of a class method.
preferTransformed = true
By default, the req.vovk methods return the incoming data as the schemas transform it. The built-in Next.js functions, such as req.json() or req.nextUrl.searchParams.get(), don’t. For the data without transforms, set preferTransformed to false. Then everything that uses the schemas (body, query, params, output, iteration) returns the original data instead. The JSON Schema emitted for output and iteration describes what the server sends: the transformed value, or with preferTransformed: false the original one.
target
The JSON Schema version the validation library emits for the procedure’s schemas: 'draft-2020-12' by default, or another version the library supports, such as 'draft-07'.
procedure Features
A procedure made with procedure() also has these properties.
fn
fn calls the procedure without an HTTP request, for SSR/PPR, server actions, AI tool execution and so on. It takes the same arguments as the generated RPC method:
const localResult = await UserController.updateUser.fn({
body: { /* ... */ },
query: { /* ... */ },
params: { /* ... */ },
disableClientValidation: false, // default
});
// the RPC method takes the same input
const rpcResult = await UserRPC.updateUser({
body: { /* ... */ },
query: { /* ... */ },
params: { /* ... */ },
disableClientValidation: false, // default
});See Calling Procedures Locally.
schema
const schema = UserController.updateUser.schema;
// same as UserRPC.updateUser.schemaschema holds the method schema, the same as the RPC method’s. It is often used with fn to build AI tools that call handlers without HTTP.
definition
const bodyModel = UserController.updateUser.definition.body;definition holds the original procedure definition. It exists on server-side methods only, not on RPC methods.