Skip to content

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 safety
getPublicProfile: 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.

A resource schema groups fields into three visibility levels:

  • public — safe for anyone, including unauthenticated users and cached CDN responses
  • authenticated — contains PII or user-specific data that requires a valid session
  • admin — 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().

Velox TS provides four ways to project data from a resource schema. Choose the one that fits your use case.

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 resource
getPublicProfile: 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 endpoint
getProfile: 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 endpoint
getFullProfile: 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 fields
getFullProfile: 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.

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 level
getPublicProfile: 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 authenticated
getProfile: 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
}),

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

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);
}),

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 level
type 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 automatically
const { data } = api.users.getPublicProfile.useQuery({ id });
// data is typed as PublicUser

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 schemas
const 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 visibility
const 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);
}),
});

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().

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 groups
const 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 methods
const 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.

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, admin

Given 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'])

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.

All projection methods work identically with custom levels. Tagged views use the level name as the property:

// Tagged views — explicit projection
resource(data, ArticleSchema.reviewer); // reviewer view
resource(data, ArticleSchema.moderator); // moderator view
resourceCollection(items, ArticleSchema.public);
// String-based projection via forLevel()
new Resource(data, ArticleSchema).forLevel('reviewer');
new ResourceCollection(items, ArticleSchema).forLevel('moderator');

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);
}),
});

.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.

defineAccessLevels() throws at module initialization time if you pass invalid configuration:

ConditionError
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"

  1. Define once, project everywhere — Create resource schemas in a shared location and reuse them across endpoints. One source of truth for field visibility.

  2. Fetch all fields from DB, let Resource API filter — Don’t use Prisma’s select to 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 filter
    const user = await ctx.db.user.findUnique({ where: { id } });
    return resource(user, UserSchema.public);
    // Bad — manual field selection loses flexibility
    const user = await ctx.db.user.findUnique({
    where: { id },
    select: { id: true, name: true }, // can't easily switch to authenticated view
    });
  3. 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.

  4. Mark sensitive fields as admin only — Defense in depth. Even if a guard fails, admin-only fields won’t appear in lower-level projections.

  5. 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.

  • Procedures — builder methods (.output(), .guard())
  • Nested Relations.hasOne() and .hasMany() for related data
  • Schemas — Zod validation patterns
  • Guards — authorization and access levels