Skip to content

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

Terminal window
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.tsx
import { 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>
);
}

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.

Procedure NameHTTP MethodPath
listUsersGET/users
findUsersGET/users
getUserGET/users/:id
createUserPOST/users
addUserPOST/users
updateUserPUT/users/:id
editUserPUT/users/:id
patchUserPATCH/users/:id
deleteUserDELETE/users/:id
removeUserDELETE/users/:id
// These calls resolve automatically — no route config needed
await api.users.listUsers(); // GET /api/users
await api.users.getUser({ id: '1' }); // GET /api/users/1
await api.users.createUser({ name: 'Alice' }); // POST /api/users

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' },
},
},
}}
>

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' },
},
}

With createVeloxHooks, you get tRPC-style hooks with full autocomplete:

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>
);
}
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>
);
}
function UserProfile({ userId }: { userId: string }) {
const { data: user } = api.users.getUser.useQuery({ id: userId });
return user ? <h1>{user.name}</h1> : null;
}
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>;
}

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

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.

Types flow automatically from backend to frontend:

api/procedures/users.ts
// Backend defines the procedure
export 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 projection
const user = await api.users.getUser({ id: '123' });
// ^? { id: string; name: string; email: string }
// TypeScript catches errors
await api.users.getUser({ id: 123 }); // Error: Expected string