Skip to content

Nested Routes

Velox TS supports both single-level and multi-level resource nesting, allowing you to model hierarchical relationships in your REST API.

Use the .parent() method to nest a resource under a single parent resource.

import { procedures, procedure } from '@veloxts/router';
import { z } from 'zod';
// Comments nested under posts
export const commentProcedures = procedures('comments', {
// GET /posts/:postId/comments/:id
getComment: procedure()
.parent('posts')
.input(z.object({ postId: z.string(), id: z.string() }))
.query(async ({ input, ctx }) => {
return await ctx.db.comment.findUniqueOrThrow({
where: {
id: input.id,
postId: input.postId, // Ensure comment belongs to post
},
});
}),
// GET /posts/:postId/comments
listComments: procedure()
.parent('posts')
.input(z.object({ postId: z.string() }))
.query(async ({ input, ctx }) => {
return await ctx.db.comment.findMany({
where: { postId: input.postId },
});
}),
// POST /posts/:postId/comments
createComment: procedure()
.parent('posts')
.input(z.object({
postId: z.string(),
content: z.string(),
}))
.mutation(async ({ input, ctx }) => {
return await ctx.db.comment.create({
data: {
content: input.content,
postId: input.postId,
},
});
}),
});

By default, Velox TS generates parameter names by singularizing the namespace and adding Id:

  • postspostId
  • usersuserId
  • categoriescategoryId

Override this with a custom parameter name:

// GET /posts/:post_id/comments/:id
getComment: procedure()
.parent('posts', 'post_id')
.input(z.object({ post_id: z.string(), id: z.string() }))
.query(async ({ input, ctx }) => {
return await ctx.db.comment.findUniqueOrThrow({
where: { id: input.id, postId: input.post_id },
});
}),

Use the .parents() method to nest resources under multiple parent resources.

// Tasks nested under organizations and projects
export const taskProcedures = procedures('tasks', {
// GET /organizations/:orgId/projects/:projectId/tasks/:id
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 }) => {
return await ctx.db.task.findUniqueOrThrow({
where: {
id: input.id,
projectId: input.projectId,
project: {
organizationId: input.orgId,
},
},
});
}),
// GET /organizations/:orgId/projects/:projectId/tasks
listTasks: procedure()
.parents([
{ resource: 'organizations', param: 'orgId' },
{ resource: 'projects', param: 'projectId' },
])
.input(z.object({
orgId: z.string(),
projectId: z.string(),
}))
.query(async ({ input, ctx }) => {
return await ctx.db.task.findMany({
where: {
projectId: input.projectId,
project: {
organizationId: input.orgId,
},
},
});
}),
});

Parents are specified from outermost to innermost:

.parents([
{ resource: 'organizations', param: 'orgId' }, // Outermost
{ resource: 'projects', param: 'projectId' }, // Middle
{ resource: 'sprints', param: 'sprintId' }, // Innermost
])
// Generates: /organizations/:orgId/projects/:projectId/sprints/:sprintId/tasks

For deeply nested resources, accessing a single item requires knowing all parent IDs. The shortcuts option generates both nested and shortcut routes.

import { rest } from '@veloxts/router';
// Enable shortcuts in REST adapter
app.register(
rest([organizationProcedures, projectProcedures, taskProcedures], {
shortcuts: true,
}),
{ prefix: '/api' }
);

With shortcuts: true, single-resource operations generate two routes:

// Original nested route
GET /organizations/:orgId/projects/:projectId/tasks/:id
// Additional shortcut
GET /tasks/:id

Both routes call the same handler. The shortcut route extracts only the :id parameter, so your handler receives:

// Nested: { orgId: "...", projectId: "...", id: "..." }
// Flat: { id: "..." }

When using shortcuts, design handlers to work with or without parent parameters:

getTask: procedure()
.parents([
{ resource: 'organizations', param: 'orgId' },
{ resource: 'projects', param: 'projectId' },
])
.input(z.object({
orgId: z.string().optional(), // Optional for shortcuts
projectId: z.string().optional(), // Optional for shortcuts
id: z.string(),
}))
.query(async ({ input, ctx }) => {
const task = await ctx.db.task.findUniqueOrThrow({
where: { id: input.id },
include: { project: true },
});
// Validate parent IDs if provided (nested route)
if (input.orgId && task.project.organizationId !== input.orgId) {
throw new Error('Task does not belong to this organization');
}
if (input.projectId && task.projectId !== input.projectId) {
throw new Error('Task does not belong to this project');
}
return task;
}),

Velox TS warns when resource nesting exceeds 3 levels, as deeply nested APIs can become unwieldy.

// 4-level nesting triggers a warning
procedures('features', {
getFeature: procedure()
.parents([
{ resource: 'organizations', param: 'orgId' }, // Level 1
{ resource: 'projects', param: 'projectId' }, // Level 2
{ resource: 'sprints', param: 'sprintId' }, // Level 3
{ resource: 'stories', param: 'storyId' }, // Level 4
])
.input(z.object({ ... }))
.query(async ({ input, ctx }) => { ... }),
});

Console output:

⚠️ Resource 'features/getFeature' has 4 levels of nesting.
Consider using shortcuts: true or restructuring your API.

If deep nesting is intentional, disable warnings with nestingWarnings: false:

rest([featureProcedures], {
shortcuts: true,
nestingWarnings: false,
});
import { procedures, procedure, rest } from '@veloxts/router';
import { z } from 'zod';
// Posts at root level
export const postProcedures = procedures('posts', {
listPosts: procedure()
.query(async ({ ctx }) => {
const posts = await ctx.db.post.findMany();
return resourceCollection(posts, PostSchema.public);
}),
getPost: procedure()
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const post = await ctx.db.post.findUniqueOrThrow({
where: { id: input.id },
});
return resource(post, PostSchema.public);
}),
});
// Comments nested under posts
export const commentProcedures = procedures('comments', {
// GET /posts/:postId/comments
listComments: procedure()
.parent('posts')
.input(z.object({ postId: z.string() }))
.query(async ({ input, ctx }) => {
const comments = await ctx.db.comment.findMany({
where: { postId: input.postId },
orderBy: { createdAt: 'desc' },
});
return resourceCollection(comments, CommentSchema.public);
}),
// GET /posts/:postId/comments/:id
getComment: procedure()
.parent('posts')
.input(z.object({ postId: z.string(), id: z.string() }))
.query(async ({ input, ctx }) => {
const comment = await ctx.db.comment.findUniqueOrThrow({
where: { id: input.id, postId: input.postId },
});
return resource(comment, CommentSchema.public);
}),
// POST /posts/:postId/comments
createComment: procedure()
.parent('posts')
.input(z.object({
postId: z.string(),
content: z.string().min(1),
authorId: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const comment = await ctx.db.comment.create({
data: {
content: input.content,
authorId: input.authorId,
postId: input.postId,
},
});
return resource(comment, CommentSchema.authenticated);
}),
// DELETE /posts/:postId/comments/:id
deleteComment: procedure()
.parent('posts')
.input(z.object({ postId: z.string(), id: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.comment.delete({
where: { id: input.id, postId: input.postId },
});
return { success: true };
}),
});
// Register with REST adapter
app.register(
rest([postProcedures, commentProcedures]),
{ prefix: '/api' }
);

Velox TS preserves full type safety through nested routes:

// Input type includes all parent parameters
type TaskInput = {
orgId: string;
projectId: string;
id: string;
};
// Handler receives correctly typed input
getTask: procedure()
.parents([...])
.input(TaskInputSchema)
.query(async ({ input, ctx }) => {
// input.orgId - fully typed
// input.projectId - fully typed
// input.id - fully typed
}),

The input schema enforces that all parent parameters are present and validated before reaching your handler.

Nested routes are automatically included in OpenAPI documentation:

paths:
/posts/{postId}/comments:
get:
summary: List comments for a post
parameters:
- name: postId
in: path
required: true
schema:
type: string
/posts/{postId}/comments/{id}:
get:
summary: Get a specific comment
parameters:
- name: postId
in: path
required: true
- name: id
in: path
required: true

Nesting should reflect genuine parent-child relationships:

// GOOD - Comments belong to posts
GET /posts/:postId/comments/:id
// BAD - Users don't "belong" to posts
GET /posts/:postId/users/:userId

Always verify that child resources actually belong to the specified parents:

getComment: procedure()
.parent('posts')
.input(z.object({ postId: z.string(), id: z.string() }))
.query(async ({ input, ctx }) => {
// This query ENFORCES the parent relationship
return await ctx.db.comment.findUniqueOrThrow({
where: {
id: input.id,
postId: input.postId, // ✅ Validates comment belongs to post
},
});
}),

If you need more than 2 levels of nesting, enable shortcuts for direct access:

rest([...procedures], { shortcuts: true });

This provides both nested URLs (for context) and shortcut URLs (for convenience).

Instead of deep nesting for filters, use query parameters:

// AVOID - Overly nested for filtering
GET /organizations/:orgId/projects/:projectId/tasks/assigned-to/:userId
// BETTER - Flat with query params
GET /organizations/:orgId/projects/:projectId/tasks?assignedTo=:userId

Error: Handler receives undefined for parent parameter

Cause: Input schema doesn’t include the parent parameter

Fix: Add parent parameter to input schema:

.parent('posts')
.input(z.object({
postId: z.string(), // ✅ Must include parent param
id: z.string(),
}))

Cause: Route wasn’t generated correctly

Debug: Check console for nesting warnings, verify .parent() or .parents() configuration matches your input schema.

Cause: Input schema doesn’t match parent configuration

Fix: Ensure input schema includes ALL parent parameters in the same order as .parents():

.parents([
{ resource: 'organizations', param: 'orgId' },
{ resource: 'projects', param: 'projectId' },
])
.input(z.object({
orgId: z.string(), // ✅ Matches first parent
projectId: z.string(), // ✅ Matches second parent
id: z.string(),
}))