Documentation Index
Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
Use this file to discover all available pages before exploring further.
Services are standalone operation groups that aren’t tied to a single entity’s CRUD lifecycle. Use them for domain services, cross-entity workflows, or any operation that doesn’t fit the entity model.
All service routes are prefixed with /api/ by default: POST /api/{serviceName}/{actionName}. The prefix is configurable via apiPrefix in createServer().
Defining a service
import { service } from '@vertz/server';
import { rules } from '@vertz/server';
import { s } from '@vertz/schema';
const notifications = service('notifications', {
inject: {
users: usersEntity,
tasks: tasksEntity,
},
access: {
sendEmail: rules.public,
sendBulk: rules.public,
},
actions: {
sendEmail: {
body: s.object({
to: s.string().email(),
subject: s.string(),
body: s.string(),
}),
response: s.object({
id: s.string().uuid(),
status: s.literal('sent'),
}),
handler: async (input, ctx) => {
await emailService.send(input);
return { id: crypto.randomUUID(), status: 'sent' as const };
},
},
sendBulk: {
body: s.object({
userIds: s.array(s.string().uuid()),
message: s.string(),
}),
response: s.object({ sent: s.number() }),
handler: async (input, ctx) => {
return { sent: input.userIds.length };
},
},
},
});
Services vs. entity custom actions
| Entity custom actions | Services |
|---|
| Scope | Tied to a single entity instance (:id) | Independent — no parent entity |
| Route | POST /api/tasks/:id/archive | POST /api/notifications/send-email |
| Context | Gets the existing record as 3rd argument | No record — just input and context |
| Use case | Operations on a specific record | Cross-entity workflows, domain services |
Use entity custom actions when the operation targets a specific record (archive a task, mark as complete). Use services when the operation spans multiple entities or doesn’t belong to any single entity.
Service actions support an optional path property to override the generated URL segment. When
provided, the path replaces /{serviceName}/{actionName} but still respects the API prefix (e.g.,
path: 'webhooks/stripe' produces POST /api/webhooks/stripe). Prefer the default generated
paths — they keep your routes consistent and discoverable. Use path only when you need a
different URL structure, such as matching an external webhook callback URL.
Dependency injection
Services access entities through inject, just like entity hooks:
const reports = service('reports', {
inject: {
tasks: tasksEntity,
users: usersEntity,
},
actions: {
generate: {
body: s.object({ month: s.number(), year: s.number() }),
response: s.object({ totalTasks: s.number(), completionRate: s.number() }),
handler: async (input, ctx) => {
const tasks = await ctx.entities.tasks.list({
where: { /* filter by month/year */ },
});
return {
totalTasks: tasks.total,
completionRate: /* calculate */,
};
},
},
},
});
Access rules
Services use the same access control as entities — deny-by-default, with the action name as the key:
import { rules } from '@vertz/server';
access: {
sendEmail: rules.public,
sendBulk: rules.public,
// generate: undefined → route not created
}
The access block is required. If you omit it or leave an action out of it, that action’s route is silently not generated (deny-by-default). This is the most common DX trap with services — you define an action, call the endpoint, and get 404 because the access rule is missing.Always use rules.* descriptors from @vertz/server — not raw callback functions like () => true.
Content descriptors
By default, service actions are JSON-in/JSON-out using s.* schemas. For non-JSON content types (XML, HTML, plain text, binary), use content.* descriptors:
import { content, service } from '@vertz/server';
const saml = service('saml', {
actions: {
// XML GET — no body, returns XML
metadata: {
method: 'GET',
response: content.xml(),
handler: async () => '<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"/>',
},
// HTML response
sso: {
method: 'GET',
response: content.html(),
handler: async (_input, ctx) => {
return `<html><body onload="document.forms[0].submit()">
<form method="POST" action="${acsUrl}">
<input type="hidden" name="SAMLResponse" value="${response}" />
</form>
</body></html>`;
},
},
},
access: {
metadata: rules.public,
sso: rules.public,
},
});
Available descriptors
| Descriptor | Content-Type | TypeScript type |
|---|
content.xml() | application/xml | string |
content.html() | text/html | string |
content.text() | text/plain | string |
content.binary() | application/octet-stream | Uint8Array |
How it works
Content descriptors implement SchemaLike, so they work in the same body and response properties as s.* schemas. The framework handles everything:
- Response wrapping — the framework sets the correct
Content-Type header based on the descriptor
- Type inference —
content.xml() makes handler input/output typed as string, content.binary() as Uint8Array
- Content-type validation — if a request’s
Content-Type doesn’t match the descriptor, the framework returns 415 Unsupported Media Type
- XML MIME flexibility —
content.xml() accepts both application/xml and text/xml
Mixing content types
You can mix content.* and s.* in the same action — for example, XML input with JSON output:
const importer = service('import', {
actions: {
spreadsheet: {
method: 'POST',
body: content.xml(),
response: s.object({ imported: s.number() }),
handler: async (input) => {
const rows = parseSpreadsheetXml(input); // input: string
return { imported: rows.length };
},
},
},
access: { spreadsheet: rules.public },
});
Optional body
body is optional for actions that don’t receive a request body (GET, DELETE). When omitted, the handler’s input parameter is unknown:
actions: {
healthCheck: {
method: 'GET',
response: content.text(),
handler: async () => 'OK',
},
}
JSON endpoints must always use s.* schemas for body and response. Content descriptors
are for non-JSON content types only. This ensures all JSON endpoints are fully validated and
typed.
Custom response headers and status codes
By default, service actions return 200 with application/json. To customize the HTTP response headers or status code, wrap your return value with response():
import { response, service } from '@vertz/server';
import { s } from '@vertz/schema';
const cloud = service('cloud', {
access: { jwks: rules.public, createToken: rules.public },
actions: {
jwks: {
method: 'GET',
response: s.object({ keys: s.array(jwkSchema) }),
handler: async (_input, ctx) => {
const keys = await getPublicKeys(ctx);
return response(
{ keys },
{
headers: { 'Cache-Control': 'public, max-age=3600' },
},
);
},
},
createToken: {
body: tokenRequestSchema,
response: s.object({ token: s.string() }),
handler: async (input) => {
const token = await generateToken(input);
return response({ token }, { status: 201 });
},
},
},
});
Plain return values still work — response() is purely opt-in:
// These two are equivalent:
handler: async () => ({ token: 'tok' });
handler: async () => response({ token: 'tok' });
Rules
content-type is protected and cannot be overridden — the framework always sets it based on the response schema
- Custom headers are merged onto the response alongside the framework-managed
content-type
- Custom
status overrides the default 200. Error responses (4xx/5xx from access rules or validation) are not affected
response() works with both JSON and content descriptor responses
Entity custom actions also support response(). The same pattern applies — wrap the return value
to set custom headers or status codes.
Service handlers can access raw request metadata via ctx.request:
handler: async (_input, ctx) => {
const authHeader = ctx.request.headers.get('authorization');
const requestUrl = ctx.request.url;
const httpMethod = ctx.request.method;
// ctx.request.body contains the pre-parsed body
};
Atomic request handling
When a service handler performs multiple database writes that must succeed or fail together, wrap them in db.transaction():
import { service } from '@vertz/server';
const transfers = service('transfers', {
actions: {
transfer: {
body: s.object({
fromId: s.string().uuid(),
toId: s.string().uuid(),
amount: s.number().positive(),
}),
response: s.object({ success: s.boolean() }),
handler: async (input, ctx) => {
return db.transaction(async (tx) => {
await tx.accounts.update({
where: { id: input.fromId },
data: { balance: { decrement: input.amount } },
});
await tx.accounts.update({
where: { id: input.toId },
data: { balance: { increment: input.amount } },
});
return { success: true };
});
},
},
},
access: { transfer: rules.public },
});
If you have many handlers that need transactions, extract a reusable helper:
import type { TransactionClient } from '@vertz/db';
function withTransaction<T>(
fn: (tx: TransactionClient<typeof models>) => Promise<T>,
): Promise<T> {
return db.transaction(fn);
}
// Then in any handler:
handler: async (input) => withTransaction(async (tx) => {
await tx.orders.create({ data: { ... } });
await tx.inventory.update({ ... });
return { orderId: '...' };
}),
Use transactions for multi-table writes and financial operations. Read-heavy handlers that only perform a single write typically don’t need them.
Server setup
Pass services alongside entities:
createServer({
entities: [users, tasks],
services: [notifications, reports],
db,
}).listen({ port: 3000 });
Using services from the UI
Like entities, services generate typed SDK methods via codegen. Each action becomes a callable method on the API client:
import { api } from '../client';
import { form } from '@vertz/ui';
// Call a service action directly
const result = await api.notifications.sendEmail({
to: 'user@example.com',
subject: 'Hello',
body: 'World',
});
// Use with form() for form submissions
const contactForm = form(api.notifications.sendEmail, {
onSuccess: () => {
/* handle success */
},
});
NEVER use raw fetch() to call service endpoints. The generated SDK provides type safety, SSR
integration, and validation schema metadata that form() uses automatically. See SDK & Fetch
Client for details.