Controller
Create a controller with the CLI
npx vovk new controller admin/user # Create a new controller for the "admin" segmentDefinition
A controller module is a static class (a class with only static methods that is never instantiated) that handles incoming HTTP requests.
Methods decorated with an HTTP decorator (like @get() or @post()) become request handlers and accept two arguments: VovkRequest (which extends NextRequest) and parameters defined by the decorator path.
By default, the NextRequest type doesn’t provide any information about the request body or query parameters, so the body is cast as any and query parameters are cast as string | null.
import type { NextRequest } from 'next';
import { prefix, put } from 'vovk';
@prefix('users')
export default class UserController {
// Example request: PUT /api/users/69?role=moderator
@put('{id}')
static async updateUser(req: NextRequest, { id }: { id: string }) {
const data = await req.json(); // any
const userRole = req.nextUrl.searchParams.get('role'); // string | null
// ...
return updatedUser;
}
}To add the required body and query types, replace NextRequest with VovkRequest and define the types as generics.
import { prefix, put, 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;
}
}Hint: To ensure that the class is never instantiated, you can make it abstract.
export default abstract class UserController {
/* ... */
}As you can see, we only changed the type of req, but now data is Partial<User> and notify is typed as 'email' | 'push' | 'none' and no longer includes null.
Validation
To validate the request body, query parameters, and headers, you can use validation libraries. In this case, you don’t need to use VovkRequest manually, as the validation library will define the request type itself.
import { z } from 'zod';
import { withZod } from 'vovk-zod';
import { prefix, post, operation, type VovkOutput } from 'vovk';
@prefix('users')
export default class UserController {
@operation({
summary: 'Update user (Zod)',
description: 'Update user by ID with Zod validation',
})
@post('{id}')
static updateUser = withZod({
body: z
.object({
name: z.string().meta({ description: 'User full name' }),
age: z.number().min(0).max(120).meta({ description: 'User age' }),
email: z.email().meta({ description: 'User email' }),
})
.meta({ description: 'User object' }),
params: z.object({
id: z.uuid().meta({ description: 'User ID' }),
}),
query: z.object({
notify: z.enum(['email', 'push', 'none']).meta({ description: 'Notification type' }),
}),
output: z
.object({
success: z.boolean().meta({ description: 'Success status' }),
})
.meta({ description: 'Response object' }),
async handle(req, { id }) {
const { name, age } = await req.json();
const notify = req.nextUrl.searchParams.get('notify');
// do something with the data
console.log(`Updating user ${id}:`, { name, age, notify });
return {
success: true,
} satisfies VovkOutput<typeof UserController.updateUser>;
},
});
}Note that to enable safe self‑inference in service modules, the handle method doesn’t enforce a return type and can return any value. You can still use the satisfies operator to ensure the returned value matches the expected output type.
Initialization
Once the controller is defined, it needs to be initialized in a segment route by adding it to the controllers object. The key of this object defines the name of the resulting RPC module variable exported from "vovk-client" or the segmented client.
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 });This will “compile” the UserController into UserRPC, which performs fetch requests to the /api/users subpath.
import { UserRPC } from 'vovk-client';
// performs PUT /api/users/69?notify=push
const updatedUser = await UserRPC.updateUser({
query: { notify: 'push' },
params: { id: '69' },
body: { ...userData },
});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(/* ... */) {
// ...
}
}Return Types
Custom Object
The HTTP-decorated static methods of controllers can return several types of objects. For example, a regular object literal.
// ...
export default class HelloController {
@get()
static helloWorld() {
return { hello: 'world' };
}
}Another example: if the controller method returns a Prisma ORM invocation, the type will be recognized accordingly.
// ...
export default class UserController {
@get()
static async updateUser(/* ... */) {
// ...
return prisma.user.update({
where: { id },
data,
});
}
}In this case, the returned value of the client method UserRPC.updateUser gets the User type generated in @prisma/client.
Response Object
HTTP handlers can also return a regular Response object, including NextResponse.
// ...
export default class HelloController {
@get()
static helloWorld() {
return new Response(JSON.stringify({ hello: 'world' }), {
headers: { 'Content-Type': 'application/json' },
}) as unknown as { hello: string };
}
}When NextResponse.json(...) is returned from the controller method, the client library will recognize the return type as expected.
import { NextResponse } from 'next/server';
// ...
export default class HelloController {
@get()
static helloWorld() {
return NextResponse.json({ hello: 'world' }, { status: 200 });
}
}