Resource API
Why Resource API?
Section titled “Why Resource API?”A User record typically contains fields like id, name, email, and internalNotes — but not every API consumer should see every field. Anonymous visitors need a public profile; authenticated users expect to see their email; admins need access to internal metadata.
Without a structured approach, you end up writing manual field-picking logic for every endpoint:
// Without Resource API — verbose, error-prone, no type safetygetPublicProfile: procedure() .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); return { id: user.id, name: user.name }; // easy to forget a field or leak one }),
getAuthProfile: procedure() .guard(authenticated) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); return { id: user.id, name: user.name, email: user.email }; // duplicated logic }),This pattern doesn’t scale. Add a new public field and you have to update every endpoint. Accidentally include internalNotes in a public handler and you have a data leak.
The Resource API solves this by letting you define field visibility once and project automatically based on access level.
Defining a Resource Schema
Section titled “Defining a Resource Schema”A resource schema groups fields into three visibility levels:
public— safe for anyone, including unauthenticated users and cached CDN responsesauthenticated— contains PII or user-specific data that requires a valid sessionadmin— internal fields (notes, IP addresses, timestamps) that only operators need
import { resourceSchema } from '@veloxts/router';import { z } from '@veloxts/velox';
const UserSchema = resourceSchema() .public('id', z.string().uuid()) .public('name', z.string()) .public('avatarUrl', z.string().url().nullable()) .authenticated('email', z.string().email()) .authenticated('createdAt', z.date()) .admin('internalNotes', z.string().nullable()) .admin('lastLoginIp', z.string().nullable()) .build();Calling .build() returns a schema object with tagged view properties: UserSchema.public, UserSchema.authenticated, and UserSchema.admin. Each view is a pre-configured projection that includes only the fields at that level and below.
For schemas with related data (e.g., Prisma relations), see Nested Relations for .hasOne() and .hasMany().
Projection Methods
Section titled “Projection Methods”Velox TS provides four ways to project data from a resource schema. Choose the one that fits your use case.
Tagged Schema Views (Recommended)
Section titled “Tagged Schema Views (Recommended)”Pass a tagged view directly to resource() for one-liner projection. This is the recommended approach because it’s explicit, type-safe at the call site, and requires no intermediate object.
import { resource, resourceCollection } from '@veloxts/router';
// Single resourcegetPublicProfile: 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); // Returns: { id, name, avatarUrl } }),
// Authenticated endpointgetProfile: procedure() .input(z.object({ id: z.string() })) .guard(authenticated) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); return resource(user, UserSchema.authenticated); // Returns: { id, name, avatarUrl, email, createdAt } }),
// Admin endpointgetFullProfile: procedure() .input(z.object({ id: z.string() })) .guard(hasRole('admin')) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); return resource(user, UserSchema.admin); // Returns: all fields including internalNotes, lastLoginIp }),The return type is automatically narrowed — TypeScript knows exactly which fields are included at each level.
Automatic Projection with .output() and Guards
Section titled “Automatic Projection with .output() and Guards”When a guard already determines the access level, pass the corresponding tagged view to .output() on the procedure builder. The executor projects fields automatically:
import { authenticated, hasRole } from '@veloxts/auth';
// Authenticated — auto-projects { id, name, avatarUrl, email, createdAt }getProfile: procedure() .input(z.object({ id: z.string().uuid() })) .guard(authenticated) .output(UserSchema.authenticated) .query(async ({ input, ctx }) => { // Just return the full data — projection is automatic return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); }),
// Admin — auto-projects all fieldsgetFullProfile: procedure() .input(z.object({ id: z.string().uuid() })) .guard(hasRole('admin')) .output(UserSchema.admin) .query(async ({ input, ctx }) => { return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); }),How it works: The .output() method accepts tagged resource views (e.g., UserSchema.authenticated). When the handler returns, the procedure executor projects the result through the tagged view. No manual .forX() calls needed.
Auto-Detection from Context
Section titled “Auto-Detection from Context”When you don’t know the access level at coding time (e.g., a shared utility handler), use .for(ctx) to read the level from the request context at runtime:
import { resource } from '@veloxts/router';
// Public endpoint — auto-detects public access levelgetPublicProfile: procedure() .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: input.id } }); return resource(user, UserSchema).for(ctx); // Returns public view }),
// Authenticated endpoint — auto-detects authenticatedgetProfile: procedure() .input(z.object({ id: z.string() })) .guard(authenticated) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: input.id } }); return resource(user, UserSchema).for(ctx); // Returns authenticated view }),Conditional Projection
Section titled “Conditional Projection”For runtime branching that guards can’t express — like showing more fields when a user views their own profile — use tagged views with conditional logic:
import { resource } from '@veloxts/router';
getOwnProfile: procedure() .input(z.object({ id: z.string() })) .guard(authenticated) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: input.id } }); // Show more fields if viewing own profile if (user.id === ctx.user?.id) { return resource(user, UserSchema.authenticated); } return resource(user, UserSchema.public); }),When to use conditional projection:
- Conditional logic (e.g., ownership checks)
- Mixed access levels in one handler
- Non-guard-based access decisions
Resource Collections
Section titled “Resource Collections”Project arrays of resources using resourceCollection(). The same tagged view pattern applies:
import { resourceCollection } from '@veloxts/router';
// Public list — returns Array<{ id, name, avatarUrl }>listUsers: procedure() .query(async ({ ctx }) => { const users = await ctx.db.user.findMany({ take: 10 }); return resourceCollection(users, UserSchema.public); }),
// Authenticated list — returns Array<{ id, name, avatarUrl, email, createdAt }>listFullUsers: procedure() .guard(authenticated) .query(async ({ ctx }) => { const users = await ctx.db.user.findMany({ take: 10 }); return resourceCollection(users, UserSchema.authenticated); }),Type Inference
Section titled “Type Inference”The Resource API provides full compile-time type safety. The frontend client receives correctly narrowed types without code generation:
import type { PublicOutput, AuthenticatedOutput, AdminOutput } from '@veloxts/router';
// Extract output types for each access leveltype PublicUser = PublicOutput<typeof UserSchema>;// { id: string; name: string; avatarUrl: string | null }
type AuthUser = AuthenticatedOutput<typeof UserSchema>;// { id: string; name: string; avatarUrl: string | null; email: string; createdAt: Date }
type AdminUser = AdminOutput<typeof UserSchema>;// All fields including internalNotes, lastLoginIp
// Frontend client gets correct types automaticallyconst { data } = api.users.getPublicProfile.useQuery({ id });// data is typed as PublicUserComplete CRUD Example
Section titled “Complete CRUD Example”A realistic example combining input schemas, resource schemas, and procedures:
import { procedures, procedure, resourceSchema, resource, resourceCollection } from '@veloxts/router';import { z } from '@veloxts/velox';
// Input schemasconst CreateProductInput = z.object({ name: z.string().min(1).max(200), description: z.string().max(2000), price: z.number().positive(), categoryId: z.string().uuid(), tags: z.array(z.string()).max(10).default([]),});
const UpdateProductInput = CreateProductInput.partial().extend({ id: z.string().uuid(),});
// Resource schema — field visibilityconst ProductSchema = resourceSchema() .public('id', z.string().uuid()) .public('name', z.string()) .public('price', z.number()) .authenticated('description', z.string()) .authenticated('categoryId', z.string().uuid()) .authenticated('tags', z.array(z.string())) .admin('createdAt', z.date()) .admin('updatedAt', z.date()) .build();
export const productProcedures = procedures('products', { getProduct: procedure() .input(z.object({ id: z.string().uuid() })) .query(async ({ input, ctx }) => { const product = await ctx.db.product.findUniqueOrThrow({ where: { id: input.id } }); return resource(product, ProductSchema.public); }),
listProducts: procedure() .input(z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), })) .query(async ({ input, ctx }) => { const [items, total] = await Promise.all([ ctx.db.product.findMany({ skip: (input.page - 1) * input.limit, take: input.limit, }), ctx.db.product.count(), ]);
return { data: resourceCollection(items, ProductSchema.public), pagination: { page: input.page, limit: input.limit, total, totalPages: Math.ceil(total / input.limit), }, }; }),
createProduct: procedure() .input(CreateProductInput) .mutation(async ({ input, ctx }) => { const product = await ctx.db.product.create({ data: input }); return resource(product, ProductSchema.authenticated); }),
updateProduct: procedure() .input(UpdateProductInput) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; const product = await ctx.db.product.update({ where: { id }, data }); return resource(product, ProductSchema.authenticated); }),});Custom Access Levels
Section titled “Custom Access Levels”The default three-level system (public, authenticated, admin) covers most applications. When your domain has roles that don’t fit neatly into this hierarchy — content platforms with reviewers and moderators, SaaS products with team-scoped visibility, or tiered subscription plans — you can define your own access levels with defineAccessLevels().
Defining Custom Levels
Section titled “Defining Custom Levels”Call defineAccessLevels() with an array of level names and an optional groups map. Pass the result to resourceSchema() to get a Proxy-based builder where both level names and group names become fluent methods:
import { defineAccessLevels, resourceSchema, resource } from '@veloxts/router';import { z } from '@veloxts/velox';
// Define levels and groupsconst access = defineAccessLevels( ['public', 'reviewer', 'authenticated', 'moderator', 'admin'], { everyone: '*', // all 5 levels internal: ['reviewer', 'moderator', 'admin'], staff: ['moderator', 'admin'], });
// Group names and level names both become fluent builder methodsconst ArticleSchema = resourceSchema(access) .everyone('id', z.string().uuid()) .everyone('title', z.string()) .everyone('publishedAt', z.date().nullable()) .internal('reviewerNotes', z.string()) // visible to reviewer, moderator, admin .staff('moderationLog', z.string()) // visible to moderator, admin .authenticated('authorEmail', z.string()) // level method — single level only .admin('internalFlags', z.string()) // level method — single level only .build();The '*' wildcard in a group expands to all defined levels. Groups map names to explicit subsets.
Non-Hierarchical Visibility
Section titled “Non-Hierarchical Visibility”This is the most important distinction from the default builder. When you call a level method (like .authenticated() or .admin()) on a custom schema, it creates a single-level visibility set — the field is only visible to that exact level, not to higher levels.
The schema defined earlier demonstrates this clearly:
// .authenticated('authorEmail', ...) — visible ONLY to 'authenticated'// .admin('internalFlags', ...) — visible ONLY to 'admin'// .internal(...) — group: visible to reviewer, moderator, admin// .staff(...) — group: visible to moderator, adminGiven that schema, here is what each level sees:
const data = { id: '1', title: 'Hello', publishedAt: null, reviewerNotes: 'Looks good', moderationLog: 'Approved', authorEmail: 'author@example.com', internalFlags: 'FLAG_A',};
resource(data, ArticleSchema.public);// { id, title, publishedAt }
resource(data, ArticleSchema.reviewer);// { id, title, publishedAt, reviewerNotes } ← 'internal' group includes reviewer
resource(data, ArticleSchema.authenticated);// { id, title, publishedAt, authorEmail } ← level method: single level only
resource(data, ArticleSchema.moderator);// { id, title, publishedAt, reviewerNotes, moderationLog } ← 'internal' + 'staff' groups
resource(data, ArticleSchema.admin);// { id, title, publishedAt, reviewerNotes, moderationLog, internalFlags }Notice that authenticated does not see reviewerNotes (an internal group field) and moderator does not see authorEmail (a single-level .authenticated() field). This non-hierarchical model gives you fine-grained control — but it means you must be intentional about which levels see which fields.
Use .visibleTo() to express arbitrary visibility sets that don’t map to a single level or group:
// Visible to authenticated, moderator, and admin — but NOT reviewer.visibleTo('authorEmail', z.string(), ['authenticated', 'moderator', 'admin'])Replicating Hierarchical Behavior
Section titled “Replicating Hierarchical Behavior”If you want custom levels but still need higher levels to inherit lower levels’ fields (like the default system), define groups that mirror the hierarchy:
const config = defineAccessLevels( ['public', 'authenticated', 'admin'], { everyone: '*', // public + authenticated + admin loggedIn: ['authenticated', 'admin'], // authenticated + admin adminsOnly: ['admin'], });
const UserSchema = resourceSchema(config) .everyone('id', z.string()) // public, authenticated, admin .loggedIn('email', z.string()) // authenticated, admin .adminsOnly('secret', z.string()) // admin only .build();This produces identical projections to the default resourceSchema() builder.
Projection with Custom Levels
Section titled “Projection with Custom Levels”All projection methods work identically with custom levels. Tagged views use the level name as the property:
// Tagged views — explicit projectionresource(data, ArticleSchema.reviewer); // reviewer viewresource(data, ArticleSchema.moderator); // moderator viewresourceCollection(items, ArticleSchema.public);
// String-based projection via forLevel()new Resource(data, ArticleSchema).forLevel('reviewer');new ResourceCollection(items, ArticleSchema).forLevel('moderator');Custom Levels in Procedures
Section titled “Custom Levels in Procedures”Use tagged views in handlers just as you would with the default schema. The level names come from the defineAccessLevels() call:
import { procedures, procedure } from '@veloxts/velox';import { authenticated, hasRole } from '@veloxts/auth';
export const articleProcedures = procedures('articles', { // Public: returns { id, title, publishedAt } getArticle: procedure() .input(z.object({ id: z.string().uuid() })) .query(async ({ input, ctx }) => { const article = await ctx.db.article.findUniqueOrThrow({ where: { id: input.id } }); return resource(article, ArticleSchema.public); }),
// Reviewers: returns { id, title, publishedAt, reviewerNotes } reviewArticle: procedure() .input(z.object({ id: z.string().uuid() })) .guard(hasRole('reviewer')) .query(async ({ input, ctx }) => { const article = await ctx.db.article.findUniqueOrThrow({ where: { id: input.id } }); return resource(article, ArticleSchema.reviewer); }),
// Moderators: returns all staff-visible fields moderateArticle: procedure() .input(z.object({ id: z.string().uuid() })) .guard(hasRole('moderator')) .query(async ({ input, ctx }) => { const article = await ctx.db.article.findUniqueOrThrow({ where: { id: input.id } }); return resource(article, ArticleSchema.moderator); }),});Nested Relations with Custom Levels
Section titled “Nested Relations with Custom Levels”.hasOne() and .hasMany() accept group names or explicit level arrays as their visibility parameter when used with custom access level schemas:
const AuthorSchema = resourceSchema(access) .everyone('name', z.string()) .authenticated('email', z.string()) .build();
const ArticleSchema = resourceSchema(access) .everyone('title', z.string()) .hasOne('author', AuthorSchema, 'internal') // group name .hasMany('comments', CommentSchema, ['authenticated', 'admin']) // explicit array .build();See Nested Relations for full .hasOne() and .hasMany() documentation.
Validation Rules
Section titled “Validation Rules”defineAccessLevels() throws at module initialization time if you pass invalid configuration:
| Condition | Error |
|---|---|
| Fewer than 2 levels | "defineAccessLevels requires at least 2 levels" |
| Duplicate level names | "Duplicate level: 'name'" |
| Group name matches a level name | "Group name collides with a level name" |
| Group references unknown level | "Group references unknown level" |
| Empty group array | "Group must contain at least one level" |
Best Practices
Section titled “Best Practices”-
Define once, project everywhere — Create resource schemas in a shared location and reuse them across endpoints. One source of truth for field visibility.
-
Fetch all fields from DB, let Resource API filter — Don’t use Prisma’s
selectto pre-filter fields. Fetch the full object and let the projection handle visibility. This decouples data access from presentation.// Good — fetch full object, let Resource API filterconst user = await ctx.db.user.findUnique({ where: { id } });return resource(user, UserSchema.public);// Bad — manual field selection loses flexibilityconst user = await ctx.db.user.findUnique({where: { id },select: { id: true, name: true }, // can't easily switch to authenticated view}); -
Use guards with
.output()for automatic projection — When access level maps directly to a guard, use.guard()+.output(Schema.level)to eliminate manual projection calls entirely. -
Mark sensitive fields as admin only — Defense in depth. Even if a guard fails, admin-only fields won’t appear in lower-level projections.
-
Use groups to express shared visibility — When multiple levels share a set of fields, define a group rather than repeating
.visibleTo()on every field. Groups keep schemas readable and changes localized.
Related Content
Section titled “Related Content”- Procedures — builder methods (
.output(),.guard()) - Nested Relations —
.hasOne()and.hasMany()for related data - Schemas — Zod validation patterns
- Guards — authorization and access levels