Client Package
@veloxts/client provides a type-safe client for calling Velox TS APIs from React applications. It works with SPA templates (--default, --auth, --trpc).
Installation
Section titled “Installation”pnpm add @veloxts/client @tanstack/react-query// lib/api.ts - tRPC mode (baseUrl ends with /trpc)import { createVeloxHooks } from '@veloxts/client/react';import type { AppRouter } from '../../api/src/router.js';
// AppRouter is typeof router from rpc()export const api = createVeloxHooks<AppRouter>();
// app/providers.tsximport { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { VeloxProvider } from '@veloxts/client/react';import type { AppRouter } from '../../api/src/router.js';
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <VeloxProvider<AppRouter> config={{ baseUrl: 'http://localhost:3030/trpc' }}> {children} </VeloxProvider> </QueryClientProvider> );}// lib/api.ts - REST modeimport { createVeloxHooks } from '@veloxts/client/react';import type { userProcedures } from '@/api/procedures/users';
type AppRouter = { users: typeof userProcedures;};
export const api = createVeloxHooks<AppRouter>();
// app/providers.tsximport { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { VeloxProvider } from '@veloxts/client/react';
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <VeloxProvider<AppRouter> config={{ baseUrl: '/api' }}> {children} </VeloxProvider> </QueryClientProvider> );}// lib/api.ts - Promise-based (no React Query)import { createClient } from '@veloxts/client';import type { AppRouter } from '../../api/src/router.js';
// Works with both tRPC router and ProcedureCollection typesexport const api = createClient<AppRouter>({ baseUrl: 'http://localhost:3030/trpc',});
// Direct usageconst users = await api.users.listUsers();const user = await api.users.getUser({ id: '123' });Route Resolution
Section titled “Route Resolution”In REST mode, the client automatically infers HTTP methods and paths from procedure names using the same naming conventions as the server. No route configuration file is needed for standard CRUD procedures.
How Convention Inference Works
Section titled “How Convention Inference Works”| Procedure Name | HTTP Method | Path |
|---|---|---|
listUsers | GET | /users |
findUsers | GET | /users |
getUser | GET | /users/:id |
createUser | POST | /users |
addUser | POST | /users |
updateUser | PUT | /users/:id |
editUser | PUT | /users/:id |
patchUser | PATCH | /users/:id |
deleteUser | DELETE | /users/:id |
removeUser | DELETE | /users/:id |
// These calls resolve automatically — no route config neededawait api.users.listUsers(); // GET /api/usersawait api.users.getUser({ id: '1' }); // GET /api/users/1await api.users.createUser({ name: 'Alice' }); // POST /api/usersCustom Routes for .rest() Overrides
Section titled “Custom Routes for .rest() Overrides”When backend procedures use .rest() to define non-conventional paths, the client can’t infer the correct URL from the name alone. Pass a routes map to tell the client where these endpoints live.
// Server defines custom paths with .rest()const authProcedures = procedures('auth', { createSession: procedure() .input(LoginSchema) .rest({ method: 'POST', path: '/auth/login' }) .mutation(handler),
createAccount: procedure() .input(RegisterSchema) .rest({ method: 'POST', path: '/auth/register' }) .mutation(handler),
getMe: procedure() .guard(authenticated) .rest({ method: 'GET', path: '/auth/me' }) .query(handler),});// Client needs explicit routes for these non-conventional paths<VeloxProvider<AppRouter> config={{ baseUrl: '/api', routes: { auth: { createSession: { method: 'POST', path: '/auth/login', kind: 'mutation' }, createAccount: { method: 'POST', path: '/auth/register', kind: 'mutation' }, getMe: { method: 'GET', path: '/auth/me', kind: 'query' }, }, }, }}>Route Entry Format
Section titled “Route Entry Format”Each route entry accepts an object with method, path, and optional kind:
{ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: '/auth/login', // Path relative to baseUrl kind?: 'query' | 'mutation' // Overrides naming convention detection}The kind field is useful when procedure names don’t follow standard prefixes. It controls whether the hooks expose useQuery or useMutation:
routes: { payments: { // Name starts with "process" — no convention match // Without kind, hooks won't know if it's a query or mutation processPayment: { method: 'POST', path: '/payments/process', kind: 'mutation' }, },}React Hooks (Recommended)
Section titled “React Hooks (Recommended)”With createVeloxHooks, you get tRPC-style hooks with full autocomplete:
useQuery
Section titled “useQuery”import { api } from '@/lib/api';
function UserList() { // Full autocomplete: api.users.listUsers.useQuery() const { data: users, isLoading, error } = api.users.listUsers.useQuery();
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>;
return ( <ul> {users?.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> );}useMutation
Section titled “useMutation”import { api } from '@/lib/api';
function CreateUserForm() { const { mutate, isPending } = api.users.createUser.useMutation({ onSuccess: () => { // Invalidate the users list to refetch api.users.listUsers.invalidate(); }, });
return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutate({ name: formData.get('name') as string, email: formData.get('email') as string, }); }}> <input name="name" required /> <input name="email" type="email" required /> <button disabled={isPending}> {isPending ? 'Creating...' : 'Create User'} </button> </form> );}Query with Parameters
Section titled “Query with Parameters”function UserProfile({ userId }: { userId: string }) { const { data: user } = api.users.getUser.useQuery({ id: userId });
return user ? <h1>{user.name}</h1> : null;}Optimistic Updates
Section titled “Optimistic Updates”function DeleteUserButton({ userId }: { userId: string }) { const queryClient = useQueryClient();
const { mutate } = api.users.deleteUser.useMutation({ onMutate: async ({ id }) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['users', 'listUsers'] });
// Snapshot previous value const previous = queryClient.getQueryData(['users', 'listUsers']);
// Optimistically remove user queryClient.setQueryData(['users', 'listUsers'], (old: User[]) => old?.filter(u => u.id !== id) );
return { previous }; }, onError: (err, variables, context) => { // Rollback on error queryClient.setQueryData(['users', 'listUsers'], context?.previous); }, onSettled: () => { // Refetch after mutation api.users.listUsers.invalidate(); }, });
return <button onClick={() => mutate({ id: userId })}>Delete</button>;}Error Handling
Section titled “Error Handling”Catch domain errors from the server with typed code and data:
import { isVeloxClientError } from '@veloxts/client';
try { const order = await client.orders.createOrder(data);} catch (error) { if (isVeloxClientError(error)) { console.log(error.statusCode); // 422 console.log(error.code); // 'INSUFFICIENT_STOCK' console.log(error.data); // { sku: 'ABC-123', available: 3 } }}Type-Safe Error Narrowing
Section titled “Type-Safe Error Narrowing”When the server declares errors with .throws(), extract the error union for compile-time narrowing:
import type { InferProcedureErrors } from '@veloxts/client';
type OrderErrors = InferProcedureErrors<typeof client.orders.createOrder>;// → { code: 'INSUFFICIENT_STOCK'; data: { sku: string; ... } }// | { code: 'PAYMENT_FAILED'; data: { reason: string; ... } }if (isVeloxClientError(error)) { switch (error.code) { case 'INSUFFICIENT_STOCK': showStockWarning(error.data.available); break; case 'PAYMENT_FAILED': showPaymentError(error.data.reason); break; }}See Business Logic for defining domain errors on the server.
Type Inference
Section titled “Type Inference”Types flow automatically from backend to frontend:
// Backend defines the procedureexport const userProcedures = procedures('users', { getUser: procedure() .input(z.object({ id: z.string().uuid() })) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }); return resource(user, UserSchema.authenticated); }),});
// Frontend gets full type safety// Input is typed: { id: string }// Output is typed based on Resource API projectionconst user = await api.users.getUser({ id: '123' });// ^? { id: string; name: string; email: string }
// TypeScript catches errorsawait api.users.getUser({ id: 123 }); // Error: Expected stringRelated Content
Section titled “Related Content”- REST Conventions - Server-side naming patterns (mirrored by client)
- REST Overrides - Custom
.rest()paths on the server - tRPC Bridge - Server-side procedure calls
- Procedures - Define backend endpoints
- Validation - Input/output schemas