Controller & Procedure
Procedure in Vovk.ts defines a RESTful API endpoint handler. It’s implemented as a static method of a class, called “controller”, decorated with an HTTP method decorator like @get(), @post(), @del(), @put(), @patch(). A procedure is a wrapper around Next.js API route handler that accepts NextRequest object and parameters defined in the route path.
import { get, put, prefix } from 'vovk';
@prefix('users') // optional prefix for all routes in this controller
export default class UserController {
@put('{id}')
static getUser(req: NextRequest, { id }: { id: string }) {
const data = await req.json();
// ...
}
}The class itself is initialized (not instantiated) in a segment route by adding it to the controllers object accepted by initSegment function.
import { initSegment } from 'vovk';
import UserController from '../../../modules/user/UserController';
const controllers = {
UserRPC: UserController,
};
export type Controllers = typeof controllers;
export const { GET, POST, PUT, DELETE } = initSegment({ controllers });The key of this object defines the name of the resulting RPC module variable used by the client-side:
import { UserRPC } from 'vovk-client';
// performs PUT /api/users/69?notify=push
const updatedUser = await UserRPC.updateUser({
params: { id: '69' },
query: { notify: 'push' },
body: userData,
});For more information, see TypeScript Client.
Root Endpoint
In order to create a root endpoint for a segment, use no prefix and an empty path (or an empty string) in the HTTP decorator.
import { get } from 'vovk';
export default class UserController {
@get()
static async listUsers(req: NextRequest) {
// ...
}
}Auto-Generated Endpoints
All HTTP decorators provide an .auto method that generates the endpoint name from the method name, making the handler definition more RPC‑like.
import { prefix, put } from 'vovk';
@prefix('users')
export default class UserController {
// creates PUT /api/users/do-something
@put.auto()
static async doSomething(/* ... */) {
// ...
}
}Request Headers
A procedure can access any Next.js APIs, such as cookies, headers, and so on via next package imports. See Next.js documentation for details.
import { put, prefix } from 'vovk';
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');
// ...
}
}Alternatively, use req.headers from the Web Request API : req.headers.get('user-agent').
VovkRequest Type
VovkRequest mirrors the NextRequest type by adding generics for request body (json method) and query (searchParams property) parameters. This allows you to define the expected types for these parts of the request, enabling type-safe access within procedure.
import { put, prefix, type VovkRequest } from 'vovk';
import type { User } from '../../types';
@prefix('users')
export default class UserController {
// Example request: PUT /api/users/69?role=moderator
@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 but doesn’t extend NextRequest in order to keep the vovk package independent of next package. However, it replicates the documented NextRequest properties such as cookies (with get, getAll, set, delete, has, clear methods) and nextUrl (with basePath, buildId, pathname, search, and typed searchParams).
procedure Function
The procedure function is a higher-level abstraction for defining procedures with built-in validation support. It allows to specify validation schemas for the request body, query parameters, and path parameters using libraries that support both Standard Schema and Standard JSON Schema
interfaces, such as Zod , Valibot , and Arktype .
The function returns an object with .handle() method that accepts an async function that processes the request after validation succeeds. It receives a type-enhanced Request as VovkRequest<TBody, TQuery, TParams> and the params: TParams value as the second argument.
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.json();
const notify = req.nextUrl.searchParams.get('notify');
// ...
});
}If .handle() is not provided, the procedure will throw Not Implemented (501) error.
procedure Options
body, query, and params
Use body, query, and params to provide input validation schemas. These validate incoming request data before it reaches the controller handler.
output and iteration
Use output and iteration to provide output validation schemas. output is for regular JSON responses, while iteration is for JSON Lines. Both are optional and don’t affect generated RPC typings, but they enable key features like OpenAPI, AI tools, and for Python, Rust, and future clients. These schemas are not used for client-side validation.
contentType
contentType specifies the expected Content-Type for the request body. It can be a string or an array of strings. It affects several areas of the procedure:
- Client-side
bodytyping — The RPC method’sbodytype is inferred based on the content type (e.g.FormDatafor form data,stringfor text types,File | ArrayBuffer | Uint8Array | Blobfor binary types). - Server-side body parsing —
req.vovk.body()automatically parses the request body into the appropriate shape (parsed JSON object, parsedFormData,string, orFile) based on the incomingContent-Typeheader. - 415 enforcement — When
contentTypeis set, requests that arrive without a matchingContent-Typeheader receive a415 Unsupported Media Typeerror. - Wildcard matching — Supports wildcard patterns such as
'video/*','image/*', or'*/*'to accept a range of media types.
See Content Type for a full breakdown of supported types, body parsing behaviour, and examples.
disableServerSideValidation
Disables server-side validation for the specified library. Provide a boolean to disable it entirely, or an array of validation types (body, query, params, output, iteration). This does not affect generated RPC typings or client-side validation.
skipSchemaEmission
Skips emitting JSON Schema for the handler. Provide a boolean to skip entirely, or an array of validation types (body, query, params, output, iteration). This does not change RPC typings but disables features that depend on emitted schemas, including client-side validation.
validateEachIteration
Applies only to iteration. Controls whether to validate each item in the streamed response. By default, only the first iteration item is validated.
operation
Optionally provide an operation object to specify OAS details when the @operation decorator is not applicable—useful with fn on a regular function instead of a class method.
preferTransformed = true
By default, methods provided by req.vovk transform (but not the built-in Next.js functions such as req.json() or req.nextUrl.searchParams.get()) incoming data into the validation result shape. If you need raw I/O without transformations, set preferTransformed to false. This causes all features that rely on validation models (body, query, params, output, iteration) to return the original data format instead of the transformed one.
procedure Features
Handlers created with procedure function gain extra capabilities.
fn
The fn property lets you call the controller procedure directly without making an HTTP request for SSR/PPR etc. It mirrors the generated RPC handler signature and accepts the same parameters.
const result = await UserController.updateUser.fn({
body: { /* ... */ },
query: { /* ... */ },
params: { /* ... */ },
disableClientValidation: false, // default
});
// same as
const result = await UserRPC.updateUser({
body: { /* ... */ },
query: { /* ... */ },
params: { /* ... */ },
disableClientValidation: false, // default
});See Calling Procedures Locally for details.
schema
const schema = UserController.updateUser.schema;
// same as UserRPC.updateUser.schemaThe schema property exposes the method schema, mirroring the RPC method schema. It’s typically used with fn to build AI tools that invoke handlers without HTTP.
definition
const bodyModel = UserController.updateUser.definition.body;The definition property is available only on server-side methods, but not on the RPC methods. It lets you access the original procedure definition.
Service
A service is a part of Controller–Service–Repository pattern and separates business logic from request handlers. It keeps controllers focused on HTTP concerns, while the service encapsulates the business logic and data manipulation.
Like controllers, services are often written as static classes with static methods, but they do not require decorators or any special structure. The static‑class style is simply a convention—you can instead use instantiated classes, standalone functions, or plain objects. This pattern also does not require dependency injection (DI): services can be plain modules you import and call directly.
Let’s say you have the following controller class:
import { z } from 'zod';
import { procedure, prefix, post, operation } from 'vovk';
import UserService from './UserService';
@prefix('users')
export default class UserController {
@operation({
summary: 'Update user',
description: 'Update user by ID with Zod validation',
})
@post('{id}')
static updateUser = procedure({
body: z.object({ /* ... */ })
params: z.object({ /* ... */ }),
query: z.object({ /* ... */ }),
output: z.object({ /* ... */ }),
}).handle(async (req) => {
const body = await req.vovk.body();
const query = req.vovk.query();
const params = req.vovk.params();
return UserService.updateUser(body, query, params);
});
}The handle method returns the result of UserService.updateUser. That method, in turn, infers its types from the procedure, making the validation models (Zod schemas in this case) the single source of truth for input and output types, with no need to define separate types, thanks to the legendary Anders Hejlsberg for the fix in #58616 —without this TypeScript change, Vovk.ts would not be possible.
import type { VovkBody, VovkOutput, VovkParams, VovkQuery } from 'vovk';
import type UserController from './UserController';
export default class UserService {
static updateUser(
body: VovkBody<typeof UserController.updateUser>,
query: VovkQuery<typeof UserController.updateUser>,
params: VovkParams<typeof UserController.updateUser>
) {
// perform DB operations or other business logic here
console.log(body, query, params);
return { success: true, id: params.id } satisfies VovkOutput<typeof UserController.updateUser>;
}
}In other words, service methods can infer types from procedures, and procedures can call service methods without self‑referencing type issues.
Content Type
The contentType option on a procedure controls how the request body is typed on the client, how it is parsed on the server, and which Content-Type headers are accepted. When contentType is set, the server returns a 415 Unsupported Media Type error if the incoming request doesn’t carry a matching Content-Type header.
The following table summarises how each content type family maps to client-side body typing and server-side req.vovk.body() return type:
| Content Type | Client body type | req.vovk.body() return type |
|---|---|---|
application/json, *+json | TBody | Blob | Parsed JSON object |
multipart/form-data | FormData | Blob | Parsed form object |
application/x-www-form-urlencoded | URLSearchParams | FormData | Blob | Parsed form object |
text/*, text-like application types, *+xml, *+text, *+yaml, *+json-seq | string | Blob | string |
Everything else (image/*, video/*, application/octet-stream, etc.) | File | ArrayBuffer | Uint8Array | Blob | File |
Standard Request methods like req.json(), req.text(), req.blob(), and req.formData() remain available and work as usual—contentType only affects the typed helpers on req.vovk.
Wildcard patterns such as 'video/*', 'image/*', or '*/*' are supported and match any subtype within that family.
JSON (default)
When no contentType is specified, or when it is set to 'application/json', the body is typed as the schema-inferred type and parsed as JSON.
import { z } from 'zod';
import { procedure, post, prefix } from 'vovk';
@prefix('users')
export default class UserController {
@post()
static createUser = procedure({
body: z.object({
email: z.string().email(),
name: z.string(),
}),
}).handle(async (req) => {
const { email, name } = await req.vovk.body(); // { email: string; name: string }
// ...
});
}Form Data
To accept form data requests, set contentType to 'multipart/form-data'.
import { z } from 'zod';
import { procedure, post, prefix } from 'vovk';
@prefix('users')
export default class UserController {
@post()
static createUser = procedure({
contentType: 'multipart/form-data',
body: z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
}),
}).handle(async (req) => {
const { email, name } = await req.vovk.body(); // { email: string; name: string }
// or use req.formData() for the raw FormData instance
// ...
});
}The RPC method’s body type is inferred as FormData | Blob:
import { UserRPC } from 'vovk-client';
const formData = new FormData();
formData.append('email', 'user@example.com');
formData.append('name', 'John Doe');
await UserRPC.createUser({
body: formData,
});req.vovk.body() automatically parses the form data into a plain object (see req.vovk). You can also access the raw FormData instance via req.formData().
If the form data can contain one or more values for the same key, use a union of the value type and an array of the value type, because FormData doesn’t distinguish between single and multiple values.
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static createUser = procedure({
contentType: 'multipart/form-data',
body: z.object({
tags: z.union([z.array(z.string()), z.string()]),
}),
}).handle(async (req) => {
const { tags } = await req.vovk.body(); // string | string[]
// ...
});
}The same recommendation applies to files:
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static uploadFiles = procedure({
contentType: 'multipart/form-data',
body: z.object({
files: z.union([z.array(z.file()), z.file()]),
}),
}).handle(async (req) => {
const { files } = await req.vovk.body(); // File | File[]
// ...
});
}Note that client-side validation does not fully support the OpenAPI-compatible format: "binary", and file size, type, etc., are not validated on the client side.
URL-Encoded Form Data
Set contentType to 'application/x-www-form-urlencoded' to accept URL-encoded form submissions. The body is parsed the same way as multipart/form-data, but the client body type also accepts URLSearchParams.
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static submitForm = procedure({
contentType: 'application/x-www-form-urlencoded',
body: z.object({
username: z.string(),
password: z.string(),
}),
}).handle(async (req) => {
const { username, password } = await req.vovk.body();
// ...
});
}Text
For text-based content types (text/*, known text-like application types such as application/xml or application/yaml, and suffix patterns like *+xml, *+text, *+yaml, *+json-seq), the body is parsed as a string.
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static importXml = procedure({
contentType: 'application/xml',
}).handle(async (req) => {
const xmlString = await req.vovk.body(); // string
// ...
});
}Binary / File Uploads
For any content type that doesn’t fall into the above categories—such as application/octet-stream, image/*, video/*, application/pdf, etc.—the body is parsed into a File on the server. On the client side, the body accepts File | ArrayBuffer | Uint8Array | Blob.
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static uploadImage = procedure({
contentType: 'image/*',
}).handle(async (req) => {
const file = await req.vovk.body(); // File
// or use req.blob() for raw Blob access
// ...
});
}import { UserRPC } from 'vovk-client';
const file = document.querySelector('input[type="file"]').files[0];
await UserRPC.uploadImage({
body: file,
});Multiple Content Types
contentType accepts an array of strings to allow multiple content types. The body type becomes a union of all corresponding types.
import { z } from 'zod';
import { procedure, post } from 'vovk';
export default class UserController {
@post()
static importData = procedure({
contentType: ['application/json', 'text/csv'],
body: z.object({ items: z.array(z.string()) }),
}).handle(async (req) => {
const body = await req.vovk.body(); // parsed object or string, depending on the request
// ...
});
}