This guide explains the tRPC implementation in the writing application, including patterns, conventions, and best practices for working with the codebase.
Our application has been refactored to use tRPC for type-safe API communication between the client and server. This provides several benefits:
- Type Safety: Full end-to-end type safety between client and server
- Improved Developer Experience: Better autocompletion, error checking at compile time
- Simplified API Layer: No need for manual API route handlers and validation
- Reduced Boilerplate: Less code to write and maintain
src/
├── server/
│ ├── trpc.ts # tRPC server setup
│ ├── context.ts # Request context creation
│ └── routers/ # API routers
│ ├── _app.ts # Root router
│ ├── llm.ts # LLM router
│ ├── template.ts # Template router
│ ├── document.ts # Document router
│ ├── config.ts # Config router
│ ├── ai-roles.ts # AI roles router
│ └── ... # Other routers
├── utils/
│ └── trpc.ts # tRPC client setup
├── lib/
│ ├── trpc-config-store.ts # Config store with tRPC
│ ├── trpc-kv-cache-store.ts # KV Cache store with tRPC
│ └── trpc-ai-roles-store.ts # AI Roles store with tRPC
├── components/
│ ├── trpc-provider.tsx # tRPC provider for app
│ └── ... # Other components
└── app/
└── api/
└── trpc/
└── [trpc]/
└── route.ts # tRPC API handler
import { initTRPC } from '@trpc/server';
import { ZodError } from 'zod';
const t = initTRPC.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;import { inferAsyncReturnType } from '@trpc/server';
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
export function createContext({ req }: FetchCreateContextFnOptions) {
const sessionId = req.headers.get('x-session-id');
return { sessionId };
}
export type Context = inferAsyncReturnType<typeof createContext>;We follow consistent patterns for router implementation:
All inputs are validated using Zod schemas:
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const llmRouter = router({
generateText: publicProcedure
.input(z.object({
prompt: z.string(),
model: z.string(),
maxTokens: z.number().optional(),
}))
.mutation(async ({ input }) => {
// Implementation
}),
});- Queries: Used for read operations (getting data)
- Mutations: Used for write operations (creating, updating, deleting)
// Example query
getTemplates: publicProcedure
.query(async () => {
// Implementation to fetch templates
}),
// Example mutation
updateTemplate: publicProcedure
.input(z.object({
id: z.string(),
content: z.string(),
}))
.mutation(async ({ input }) => {
// Implementation to update template
}),Consistent error handling across all routers:
try {
// Implementation
return result;
} catch (error) {
console.error('Error in router:', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
cause: error,
});
}import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '@/utils/trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Our application uses Zustand stores integrated with tRPC. Each store follows a consistent pattern:
import { create } from 'zustand';
import { trpc } from '@/utils/trpc';
export interface StoreState {
// State properties
}
export interface StoreActions {
// Action methods
}
export type Store = StoreState & StoreActions;
export const useTrpcStore = create<Store>((set, get) => ({
// Initial state
// Actions that use tRPC
someAction: async (params) => {
try {
const utils = trpc.useUtils?.() || null;
if (utils) {
// Use tRPC hooks when in component context
const result = await utils.client.someRouter.someAction.mutate(params);
// Update state based on result
set({ /* updated state */ });
return result;
} else {
// Fallback for non-component contexts using fetch
const response = await fetch('/api/trpc/someRouter.someAction', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
json: params
}),
});
const data = await response.json();
// Update state based on result
set({ /* updated state */ });
return data.result.data;
}
} catch (error) {
console.error('Error in store action:', error);
// Handle error appropriately
throw error;
}
},
}));- Dual Implementation: Each action supports both tRPC hooks (for component contexts) and direct fetch (for non-component contexts)
- Error Handling: Consistent error handling
- State Updates: Store state is updated based on operation results
- Optimistic Updates: Where appropriate, state is updated optimistically before the server operation completes
Components using tRPC follow these patterns:
Components can use tRPC hooks directly:
import { trpc } from '@/utils/trpc';
export function MyComponent() {
const { data, isLoading } = trpc.someRouter.someQuery.useQuery();
const mutation = trpc.someRouter.someAction.useMutation();
const handleAction = async () => {
await mutation.mutateAsync({ /* params */ });
};
return (
// Component implementation
);
}Most components use tRPC indirectly through stores:
import { useTrpcStore } from '@/lib/trpc-store';
export function MyComponent() {
const { someState, someAction } = useTrpcStore();
const handleAction = async () => {
await someAction({ /* params */ });
};
return (
// Component implementation
);
}Always validate inputs on the server using Zod schemas:
.input(z.object({
required: z.string(),
optional: z.number().optional(),
withDefault: z.string().default('default value'),
}))Always use structured error handling:
try {
// Implementation
} catch (error) {
if (error instanceof SomeSpecificError) {
// Handle specific error
} else {
// Handle generic error
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
cause: error,
});
}
}Use optimistic updates for better UX where appropriate:
const handleUpdate = async () => {
// Optimistically update local state
set(state => ({
items: state.items.map(item =>
item.id === itemId ? { ...item, ...newData } : item
)
}));
try {
// Perform server update
await trpc.items.update.mutate({ id: itemId, ...newData });
} catch (error) {
// Revert optimistic update on error
set(state => ({ items: originalItems }));
throw error;
}
};Use consistent prefixes for tRPC-integrated components:
Trpcprefix for components using tRPC directly- Example:
TrpcDocumentView,TrpcSettingsPanel
Use consistent naming for tRPC stores:
useTrpcprefix for store hooks- Example:
useTrpcDocumentStore,useTrpcLLMStore
If you encounter TypeScript errors about properties not existing on types:
- Ensure your router is properly exported in the root router (
_app.ts) - Check that the return types in your router match the expected types in the client
- If using tRPC utils outside components, implement proper fallbacks
If you encounter React Query related errors:
- Check that you're using hooks within React components
- Ensure you have the proper Provider setup
- Verify that your query/mutation keys are unique
If you encounter network errors:
- Check that your API endpoint is correctly configured (
/api/trpc/[trpc]/route.ts) - Verify the correct URL is used in the client setup
- Check for CORS issues if working in a development environment
When migrating existing components to use tRPC:
- Create a new version of the component with the
Trpcprefix - Implement the component using tRPC stores or hooks
- Test the new component thoroughly
- Replace the old component with the new one in the application
- Update all imports to use the new component
This pattern allows for gradual migration without disrupting existing functionality.
Testing tRPC implementations:
- Use the integration test page for manual testing
- Create unit tests for individual routers
- Create integration tests for the combined client/server functionality
- Test error scenarios by mocking failures