Skip to content

REST Naming Conventions

Velox TS uses naming conventions to automatically generate REST endpoints from procedure names. Understanding these conventions is essential for predictable API design.

The procedure name prefix determines the HTTP method. The resource name (first argument to procedures()) determines the path.

// Resource: 'users' → Path: /api/users
export const userProcedures = procedures('users', {
listUsers: ..., // GET /api/users
getUser: ..., // GET /api/users/:id
createUser: ..., // POST /api/users
});

Velox TS supports nested resources for modeling hierarchical relationships. See the Nested Routes guide for full documentation.

// Single-level nesting: GET /posts/:postId/comments/:id
export const commentProcedures = procedures('comments', {
getComment: procedure()
.parent('posts')
.input(z.object({ postId: z.string(), id: z.string() }))
.query(async ({ input, ctx }) => { ... }),
});
// Multi-level nesting: GET /organizations/:orgId/projects/:projectId/tasks/:id
export const taskProcedures = procedures('tasks', {
getTask: procedure()
.parents([
{ resource: 'organizations', param: 'orgId' },
{ resource: 'projects', param: 'projectId' },
])
.input(z.object({ orgId: z.string(), projectId: z.string(), id: z.string() }))
.query(async ({ input, ctx }) => { ... }),
});
PrefixPathUse Case
list*/api/{resource}Get collection
get*/api/{resource}/:idGet single by ID
find*/api/{resource}Search/filter collection
procedures('products', {
listProducts: ..., // GET /api/products
getProduct: ..., // GET /api/products/:id
findProducts: ..., // GET /api/products (with query params)
});
PrefixPathStatus Code
create*/api/{resource}201 Created
add*/api/{resource}201 Created
procedures('users', {
createUser: ..., // POST /api/users → 201
addUser: ..., // POST /api/users → 201
});
PrefixPathUse Case
update*/api/{resource}/:idReplace entire resource
edit*/api/{resource}/:idReplace entire resource
procedures('posts', {
updatePost: ..., // PUT /api/posts/:id
editPost: ..., // PUT /api/posts/:id
});
PrefixPathUse Case
patch*/api/{resource}/:idUpdate specific fields
procedures('users', {
patchUser: ..., // PATCH /api/users/:id
});
PrefixPathStatus Code
delete*/api/{resource}/:id200 or 204
remove*/api/{resource}/:id200 or 204
procedures('posts', {
deletePost: ..., // DELETE /api/posts/:id
removePost: ..., // DELETE /api/posts/:id
});
PrefixHTTP MethodPath PatternResponse
get*GET/:idSingle resource
list*GET/Collection
find*GET/Filtered collection
create*POST/201 + created resource
add*POST/201 + created resource
update*PUT/:idUpdated resource
edit*PUT/:idUpdated resource
patch*PATCH/:idUpdated resource
delete*DELETE/:id200/204
remove*DELETE/:id200/204
import { resourceSchema, resource, resourceCollection } from '@veloxts/router';
const UserSchema = resourceSchema()
.public('id', z.string())
.public('name', z.string())
.authenticated('email', z.string())
.build();
procedures('users', {
listUsers: procedure()
.query(async ({ ctx }) => {
const users = await ctx.db.user.findMany();
return resourceCollection(users, UserSchema.public);
}),
// → GET /api/users
getUser: procedure()
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
return resource(user, UserSchema.public);
}),
// → GET /api/users/:id
createUser: procedure()
.input(CreateUserSchema)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.create({ data: input });
return resource(user, UserSchema.authenticated);
}),
// → POST /api/users (201)
updateUser: procedure()
.input(z.object({ id: z.string(), data: UpdateUserSchema }))
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.update({ where: { id: input.id }, data: input.data });
return resource(user, UserSchema.authenticated);
}),
// → PUT /api/users/:id
deleteUser: procedure()
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.user.delete({ where: { id: input.id } });
return { success: true };
}),
// → DELETE /api/users/:id
});

Override conventions when they don’t fit:

procedures('auth', {
// Custom path → POST /api/auth/login
login: procedure()
.input(LoginSchema)
.rest({ method: 'POST', path: '/auth/login' })
.mutation(handler),
// Custom path with params → POST /api/auth/verify/:token
verifyEmail: procedure()
.input(z.object({ token: z.string() }))
.rest({ method: 'POST', path: '/auth/verify/:token' })
.mutation(handler),
});
procedures('users', {
// POST /api/users/bulk
createManyUsers: procedure()
.input(z.array(CreateUserSchema))
.rest({ method: 'POST', path: '/users/bulk' })
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.createMany({ data: input });
}),
// DELETE /api/users/bulk
deleteManyUsers: procedure()
.input(z.object({ ids: z.array(z.string()) }))
.rest({ method: 'DELETE', path: '/users/bulk' })
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.deleteMany({
where: { id: { in: input.ids } },
});
}),
});
procedures('users', {
// POST /api/users/:id/activate
activateUser: procedure()
.input(z.object({ id: z.string() }))
.rest({ method: 'POST', path: '/users/:id/activate' })
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.update({
where: { id: input.id },
data: { isActive: true },
});
}),
// POST /api/users/:id/verify-email
verifyEmail: procedure()
.input(z.object({ id: z.string(), token: z.string() }))
.rest({ method: 'POST', path: '/users/:id/verify-email' })
.mutation(async ({ input, ctx }) => {
return await ctx.auth.verifyEmail(input.id, input.token);
}),
});
procedures('products', {
// GET /api/products?page=1&limit=20&category=electronics
listProducts: procedure()
.input(z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
category: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const skip = (input.page - 1) * input.limit;
const [products, total] = await Promise.all([
ctx.db.product.findMany({
where: { category: input.category },
skip,
take: input.limit,
}),
ctx.db.product.count({ where: { category: input.category } }),
]);
return {
data: products,
meta: { page: input.page, limit: input.limit, total },
};
}),
});

Velox TS automatically extracts URL path parameters and merges them with the input schema.

For procedures expecting :id (like getUser, updateUser, deleteUser):

getUser: procedure()
.input(z.object({ id: z.string() })) // Input schema has 'id'
.query(async ({ input, ctx }) => {
// input.id comes from URL: GET /api/users/abc-123
return await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
}),
// → GET /api/users/:id

Request: GET /api/users/abc-123 Handler receives: input = { id: "abc-123" }

For custom paths with multiple parameters:

getComment: procedure()
.input(z.object({
postId: z.string(),
commentId: z.string(),
}))
.rest({ method: 'GET', path: '/posts/:postId/comments/:commentId' })
.query(({ input }) => {
// input.postId = from URL
// input.commentId = from URL
}),

Request: GET /api/posts/post-1/comments/comment-5 Handler receives: input = { postId: "post-1", commentId: "comment-5" }

Velox TS includes development-time warnings to catch naming convention issues.

No Convention Match:

// WARNING: "fetchUser" doesn't match any naming convention
fetchUser: procedure().query(...) // Should be: getUser

Type Mismatch:

// WARNING: "getUser" uses "get" prefix but is defined as mutation
getUser: procedure().mutation(...) // Should be .query()

Similar Name Detected:

// WARNING: "retrieveUser" - did you mean "getUser"?
retrieveUser: procedure().query(...)
// Disable warnings for specific namespace
export const legacyProcedures = procedures('legacy', procs, {
warnings: false,
});
// Strict mode (warnings become errors)
export const apiProcedures = procedures('api', procs, {
warnings: 'strict',
});
// Exclude specific procedures
export const mixedProcedures = procedures('users', procs, {
warnings: { except: ['customAction'] },
});
// BAD: GET prefix with mutation
getUserData: procedure()
.mutation(({ ctx }) => ctx.db.user.findMany()) // Should be .query()
// GOOD: Match type to prefix
listUsers: procedure()
.query(async ({ ctx }) => {
return await ctx.db.user.findMany();
})
// BAD: get* expects :id but no input
getUser: procedure()
.query(({ ctx }) => ctx.db.user.findMany()) // Returns all users!
// GOOD: Provide id input
getUser: procedure()
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
})
// BAD: Convention already does this
getUser: procedure()
.rest({ method: 'GET', path: '/users/:id' }) // Redundant!
.query(...)
// GOOD: Let conventions work
getUser: procedure()
.query(...) // Auto-generates GET /api/users/:id

Causes: Procedure name doesn’t match convention, wrong type (query vs mutation), or incorrect casing.

Fix: Check dev console for warnings, use standard prefix, or add .rest() override.

Cause: Using .mutation() when you meant .query() or vice versa.

Rule of thumb:

  • get*, list*, find* → Always .query()
  • create*, add*, update*, edit*, patch*, delete*, remove* → Always .mutation()

listUsers and findUsers both generate GET /api/users. Override one with .rest() to avoid conflict:

listUsers: procedure()
.query(...),
// → GET /api/users (keep default)
findUsers: procedure()
.rest({ path: '/users/search' })
.query(...),
// → GET /api/users/search (override)