Nested Routes
Velox TS supports both single-level and multi-level resource nesting, allowing you to model hierarchical relationships in your REST API.
Single-Level Nesting
Section titled “Single-Level Nesting”Use the .parent() method to nest a resource under a single parent resource.
Basic Usage
Section titled “Basic Usage”import { procedures, procedure } from '@veloxts/router';import { z } from 'zod';
// Comments nested under postsexport 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, }, }); }),});Custom Parameter Names
Section titled “Custom Parameter Names”By default, Velox TS generates parameter names by singularizing the namespace and adding Id:
posts→postIdusers→userIdcategories→categoryId
Override this with a custom parameter name:
// GET /posts/:post_id/comments/:idgetComment: 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 }, }); }),Multi-Level Nesting
Section titled “Multi-Level Nesting”Use the .parents() method to nest resources under multiple parent resources.
Basic Usage
Section titled “Basic Usage”// Tasks nested under organizations and projectsexport 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, }, }, }); }),});Parent Array Order
Section titled “Parent Array Order”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/tasksShortcuts
Section titled “Shortcuts”For deeply nested resources, accessing a single item requires knowing all parent IDs. The shortcuts option generates both nested and shortcut routes.
Enabling Shortcuts
Section titled “Enabling Shortcuts”import { rest } from '@veloxts/router';
// Enable shortcuts in REST adapterapp.register( rest([organizationProcedures, projectProcedures, taskProcedures], { shortcuts: true, }), { prefix: '/api' });Generated Routes
Section titled “Generated Routes”With shortcuts: true, single-resource operations generate two routes:
// Original nested routeGET /organizations/:orgId/projects/:projectId/tasks/:id
// Additional shortcutGET /tasks/:idBoth routes call the same handler. The shortcut route extracts only the :id parameter, so your handler receives:
// Nested: { orgId: "...", projectId: "...", id: "..." }// Flat: { id: "..." }Handler Compatibility
Section titled “Handler Compatibility”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; }),Nesting Depth Warnings
Section titled “Nesting Depth Warnings”Velox TS warns when resource nesting exceeds 3 levels, as deeply nested APIs can become unwieldy.
Warning Example
Section titled “Warning Example”// 4-level nesting triggers a warningprocedures('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.Disabling Warnings
Section titled “Disabling Warnings”If deep nesting is intentional, disable warnings with nestingWarnings: false:
rest([featureProcedures], { shortcuts: true, nestingWarnings: false,});Complete Example
Section titled “Complete Example”import { procedures, procedure, rest } from '@veloxts/router';import { z } from 'zod';
// Posts at root levelexport 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 postsexport 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 adapterapp.register( rest([postProcedures, commentProcedures]), { prefix: '/api' });import { procedures, procedure, rest } from '@veloxts/router';import { z } from 'zod';
// Organizations at root levelexport const organizationProcedures = procedures('organizations', { listOrganizations: procedure() .query(async ({ ctx }) => { return await ctx.db.organization.findMany(); }),});
// Projects nested under organizationsexport const projectProcedures = procedures('projects', { listProjects: procedure() .parent('organizations') .input(z.object({ organizationId: z.string() })) .query(async ({ input, ctx }) => { return await ctx.db.project.findMany({ where: { organizationId: input.organizationId }, }); }),});
// Tasks nested under organizations AND projectsexport const taskProcedures = procedures('tasks', { // 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(), status: z.enum(['todo', 'in_progress', 'done']).optional(), })) .query(async ({ input, ctx }) => { return await ctx.db.task.findMany({ where: { projectId: input.projectId, status: input.status, project: { organizationId: input.orgId, }, }, }); }),
// GET /organizations/:orgId/projects/:projectId/tasks/:id // Also generates: GET /tasks/:id (with shortcuts) getTask: procedure() .parents([ { resource: 'organizations', param: 'orgId' }, { resource: 'projects', param: 'projectId' }, ]) .input(z.object({ orgId: z.string().optional(), projectId: z.string().optional(), id: z.string(), })) .query(async ({ input, ctx }) => { const task = await ctx.db.task.findUniqueOrThrow({ where: { id: input.id }, include: { project: true }, });
// Validate parent context if provided if (input.projectId && task.projectId !== input.projectId) { throw new Error('Task not found in this project'); } if (input.orgId && task.project.organizationId !== input.orgId) { throw new Error('Task not found in this organization'); }
return task; }),
// POST /organizations/:orgId/projects/:projectId/tasks createTask: procedure() .parents([ { resource: 'organizations', param: 'orgId' }, { resource: 'projects', param: 'projectId' }, ]) .input(z.object({ orgId: z.string(), projectId: z.string(), title: z.string(), description: z.string().optional(), })) .mutation(async ({ input, ctx }) => { // Verify project belongs to organization const project = await ctx.db.project.findUniqueOrThrow({ where: { id: input.projectId, organizationId: input.orgId, }, });
return await ctx.db.task.create({ data: { title: input.title, description: input.description, projectId: project.id, }, }); }),});
// Register with shortcuts enabledapp.register( rest( [organizationProcedures, projectProcedures, taskProcedures], { shortcuts: true } ), { prefix: '/api' });Type Safety
Section titled “Type Safety”Velox TS preserves full type safety through nested routes:
// Input type includes all parent parameterstype TaskInput = { orgId: string; projectId: string; id: string;};
// Handler receives correctly typed inputgetTask: 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.
OpenAPI Support
Section titled “OpenAPI Support”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: trueBest Practices
Section titled “Best Practices”Use Nesting for True Hierarchies
Section titled “Use Nesting for True Hierarchies”Nesting should reflect genuine parent-child relationships:
// GOOD - Comments belong to postsGET /posts/:postId/comments/:id
// BAD - Users don't "belong" to postsGET /posts/:postId/users/:userIdValidate Parent Relationships
Section titled “Validate Parent Relationships”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 }, }); }),Consider Shortcuts for Deep Nesting
Section titled “Consider Shortcuts for Deep Nesting”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).
Use Query Params for Filtering
Section titled “Use Query Params for Filtering”Instead of deep nesting for filters, use query parameters:
// AVOID - Overly nested for filteringGET /organizations/:orgId/projects/:projectId/tasks/assigned-to/:userId
// BETTER - Flat with query paramsGET /organizations/:orgId/projects/:projectId/tasks?assignedTo=:userIdTroubleshooting
Section titled “Troubleshooting”Missing parent parameter in input
Section titled “Missing parent parameter in input”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(),}))404 on nested route
Section titled “404 on nested route”Cause: Route wasn’t generated correctly
Debug: Check console for nesting warnings, verify .parent() or .parents() configuration matches your input schema.
Type error on handler input
Section titled “Type error on handler input”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(),}))Related Content
Section titled “Related Content”- REST Conventions - Naming patterns for REST routes
- REST Overrides - Manual route configuration
- OpenAPI - API documentation generation