You've reached the API layer, the backend core of the React Starter Kit. This is where we leverage tRPC for end-to-end type safety, connecting your React frontend to a high-performance tRPC API server. The architecture is designed for building robust, maintainable, and scalable APIs from day one.
Our core stack includes tRPC for end-to-end type safety, Better Auth for authentication, and Drizzle ORM for database interactions. This API package is designed to be environment-agnostic, allowing for seamless deployment to various platforms such as Cloudflare Workers (/edge), Vercel Edge Functions, or Google Cloud Run.
- End-to-end type safety: Your API contracts are enforced at compile time, not runtime surprises
- No code generation: Unlike GraphQL, there's no build step to mess up your CI/CD pipeline
- Auto-completion everywhere: Your IDE knows what methods exist before you do
- Lightweight: Ships almost no runtime code to your client bundle
- Edge-ready: Works perfectly with Cloudflare Workers and other edge runtimes
api/
├── lib/ # Core API infrastructure
│ ├── context.ts # tRPC context setup (request, session, database)
│ ├── hono.ts # Hono framework integration
│ ├── loaders.ts # DataLoader utilities for efficient queries
│ └── trpc.ts # tRPC router and procedure setup
├── routers/ # API route definitions
│ ├── app.ts # Application-level routes
│ ├── user.ts # User management endpoints
│ └── organization.ts # Multi-tenant organization routes
├── router.ts # Main router that combines all sub-routers
├── index.ts # Package exports and type definitions
└── package.json # Scripts and dependencies
Our API uses a three-tier security model:
- Public procedures: Anyone can call these (health checks, public data)
- Protected procedures: Requires valid session (user profile, organization data)
- Role-based procedures: Requires specific permissions (admin actions, billing)
Every API call gets a rich context object containing:
type TRPCContext = {
req: Request; // Original HTTP request
info: CreateHTTPContextOptions["info"]; // Request metadata
db: ReturnType<typeof drizzle>; // Database connection
session: Session | null; // User session (if authenticated)
cache: Map<string | symbol, unknown>; // Request-scoped cache
res?: Response; // Optional response object for Hono
resHeaders?: Headers; // Optional headers for Hono
env?: CloudflareEnv; // Environment variables for Cloudflare
};We use DataLoader to solve the N+1 query problem and provide intelligent caching:
- Batch loading: Multiple requests for the same resource type get batched
- Request-scoped caching: Prevents duplicate queries within a single request
- Type-safe loaders: Each loader is typed for specific database entities
The main router combines all feature-specific routers:
export const mainRouter = router({
app: appRouter, // Application metadata, health checks
user: userRouter, // User profile management
organization: organizationRouter, // Multi-tenant features
});Each domain gets its own router for better organization:
me: Get current user profileupdateProfile: Update user informationlist: Paginated user listing (admin only)
- Multi-tenant SaaS features
- Member management
- Role-based access control
- Invitation system
- Health checks and status endpoints
- Application metadata
- Public configuration data
# Build API types
bun --filter api build
# Run type checking
bun --filter api typecheck
# Run tests
bun --filter api test
# Watch mode for development
bun --filter api test --watch-
Choose the right router: Does it belong in
user.ts,organization.ts, or needs a new router? -
Define your procedure:
export const myRouter = router({ myEndpoint: protectedProcedure .input( z.object({ name: z.string().min(1), optional: z.number().optional(), }), ) .mutation(async ({ input, ctx }) => { // Your business logic here return { success: true }; }), });
-
Add to main router in
router.ts:export const mainRouter = router({ // ... existing routers myFeature: myRouter, });
-
Test your endpoint with proper error handling and validation
Every input should be validated using Zod:
.input(z.object({
email: z.string().email("Please provide a valid email"),
name: z.string().min(1, "Name is required").max(50, "Name too long"),
age: z.number().int().min(0).max(120).optional(),
preferences: z.object({
theme: z.enum(["light", "dark"]),
notifications: z.boolean(),
}).optional(),
}))Use tRPC's built-in error system for consistent error responses:
import { TRPCError } from "@trpc/server";
// In your procedure
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
cause: originalError, // Optional: include original error
});
}Common error codes:
BAD_REQUEST: Invalid input or malformed requestUNAUTHORIZED: Authentication requiredFORBIDDEN: Insufficient permissionsNOT_FOUND: Resource doesn't existINTERNAL_SERVER_ERROR: Something went wrong on our end
Our authentication system integrates seamlessly with tRPC through the context:
// In a protected procedure
const { session } = ctx;
if (!session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const userId = session.userId; // Type-safe user IDSessions are automatically validated and included in the context. The protectedProcedure base ensures only authenticated users can access protected endpoints.
DataLoaders are pre-configured for common queries:
// Efficient user loading with automatic batching
const user = await userById(ctx).load(userId);
// Multiple users loaded in a single query
const users = await userById(ctx).loadMany([id1, id2, id3]);The context includes a cache Map for storing expensive computations:
const cacheKey = `expensive-calculation-${input.id}`;
if (ctx.cache.has(cacheKey)) {
return ctx.cache.get(cacheKey);
}
const result = await expensiveOperation(input);
ctx.cache.set(cacheKey, result);
return result;The API is designed to run on Cloudflare Workers with zero configuration:
- Edge deployment: API runs close to your users globally
- Neon PostgreSQL database: Integrated with our Drizzle ORM setup
- Environment variables: Managed through Cloudflare dashboard
- Request context: Includes Cloudflare-specific environment and bindings
The API package exports all necessary types for frontend integration:
// Frontend usage
import type { AppRouter } from "@repo/api";
import { createTRPCClient } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
// ... configuration
});
// Fully typed API calls
const user = await client.user.me.query();
const updated = await client.user.updateProfile.mutate({
name: "New Name",
});Each router should have corresponding tests:
// tests/user.test.ts
import { describe, it, expect } from "vitest";
import { createMockContext } from "./helpers";
import { userRouter } from "../routers/user";
describe("userRouter", () => {
it("should return current user profile", async () => {
const ctx = createMockContext({ userId: "123" });
const caller = userRouter.createCaller(ctx);
const result = await caller.me();
expect(result.id).toBe("123");
});
});Test the complete request flow including authentication and database operations.
If you're getting type errors after adding new endpoints:
- Rebuild the API package:
bun --filter api build - Check your imports: Make sure you're importing from the right path
- Verify exports: New routers must be exported from
router.ts
Common issues and solutions:
- "Context not found": Make sure your middleware is properly configured
- "Procedure not found": Check that your router is added to the main router
- Database connection errors: Verify your Neon database is set up and accessible
- Slow queries: Check if you're using DataLoaders properly
- Memory leaks: Ensure you're not storing large objects in the request cache
- High latency: Consider adding more aggressive caching strategies
- Always validate inputs with Zod schemas
- Use protected procedures for authenticated endpoints
- Implement proper authorization checks in your business logic
- Sanitize database inputs to prevent injection attacks
- Never expose sensitive data in API responses
- Use DataLoaders for database queries
- Implement request-scoped caching for expensive operations
- Paginate large datasets with cursor-based pagination
- Optimize database queries with proper indexes
- Keep routers focused on single domains
- Extract common logic into reusable functions
- Write comprehensive tests for business logic
- Document complex procedures with JSDoc comments
- Use meaningful error messages that help debugging
Thanks to our package.json exports, you can import API components cleanly:
// Import the main router and types
import { appRouter, type AppRouter } from "@repo/api";
// Import specific utilities
import { type TRPCContext } from "@repo/api";
// Import Hono integration
import { createHonoHandler } from "@repo/api/hono";Create reusable middleware for common patterns:
const auditMiddleware = t.middleware(async ({ ctx, next }) => {
const start = Date.now();
const result = await next();
// Log API usage for analytics
console.log(`${ctx.req.method} ${ctx.req.url} - ${Date.now() - start}ms`);
return result;
});
export const auditedProcedure = publicProcedure.use(auditMiddleware);While primarily designed for HTTP, tRPC supports subscriptions through WebSockets for real-time features.
When updating from older API patterns:
- REST to tRPC: Convert REST endpoints to tRPC procedures
- GraphQL to tRPC: Map GraphQL resolvers to tRPC procedures
- Custom auth to Better Auth: Update authentication middleware
When adding new API features:
- Follow the existing patterns for consistency
- Add proper input validation with Zod
- Include comprehensive tests
- Update type exports if needed
- Document breaking changes
- Consider backwards compatibility
Remember: A well-designed API is like a good joke � if you have to explain it, it's probably not that good. But unlike jokes, APIs should be thoroughly documented <�
- tRPC Documentation
- Better Auth Documentation
- Zod Documentation
- DataLoader Documentation
- Cloudflare Workers Documentation
An API without types is like a contract written in disappearing ink 📝✨ — it looks good until you try to use it in production.
— 🧙♂️ Ancient TypeScript Proverb