Client Generation
Superfunction provides a fully type-safe RPC-style API client that infers types directly from your router definition—no code generation required.
Type-Safe Client
The client is created from your router type, providing full end-to-end type safety:
typescript// src/server/router.ts import { route, defineRouter } from '@spfn/core/route'; import { Type } from '@sinclair/typebox'; export const getUser = route.get('/users/:id') .input({ params: Type.Object({ id: Type.String() }) }) .handler(async (c) => { const { params } = await c.data(); return { id: params.id, name: 'John Doe' }; }); export const createUser = route.post('/users') .input({ body: Type.Object({ name: Type.String(), email: Type.String() }) }) .handler(async (c) => { const { body } = await c.data(); return { id: '1', ...body }; }); export const appRouter = defineRouter({ getUser, createUser, }); export type AppRouter = typeof appRouter;
typescript// src/lib/api.ts import { createApi } from '@spfn/core/client'; import type { AppRouter } from '@/server/router'; export const api = createApi<AppRouter>();
No Code Generation Required
Unlike contract-based approaches, the RPC client:
- Infers types directly from router definition
- No
spfn codegenstep needed- Types update instantly when routes change
- Zero runtime overhead for type resolution
Client Structure
The generated client provides a simple RPC-style API:
typescriptimport { api } from '@/lib/api'; // GET requests (no body) - params, query, headers await api.getUser.call({ params: { id: '123' } }); await api.getUsers.call({ query: { page: 1, limit: 10 } }); // POST/PUT/DELETE requests - body, params, etc. await api.createUser.call({ body: { name: 'John', email: 'john@example.com' } }); await api.updateUser.call({ params: { id: '123' }, body: { name: 'Jane' } }); await api.deleteUser.call({ params: { id: '123' } });
Using the Client
Server Components (Recommended)
Use the client directly in Next.js Server Components:
typescript// app/users/[id]/page.tsx (Server Component) import { api } from '@/lib/api'; export default async function UserPage({ params }: { params: { id: string } }) { // Direct API call - no useState, no useEffect const user = await api.getUser.call({ params: { id: params.id } }); return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); }
Client Components
Use with React hooks for interactive features:
typescript'use client'; import { useState } from 'react'; import { api } from '@/lib/api'; export function CreateUserForm() { const [formData, setFormData] = useState({ name: '', email: '' }); const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); try { const user = await api.createUser.call({ body: formData }); console.log('Created user:', user.id); } catch (error) { console.error('Failed to create user:', error); } finally { setLoading(false); } }; return <form onSubmit={handleSubmit}>{/* ... */}</form>; }
API Method Patterns
GET Requests
typescript// GET /users/:id - path params only const user = await api.getUser.call({ params: { id: '123' } }); // GET /users - query params only const users = await api.getUsers.call({ query: { page: 1, limit: 20, published: true } }); // GET /users/:id/posts - both params and query const posts = await api.getUserPosts.call({ params: { userId: '123' }, query: { status: 'published' } }); // GET request without any input const health = await api.healthCheck.call({});
POST Requests
typescript// POST /users - body only const user = await api.createUser.call({ body: { name: 'Engineering', email: 'eng@example.com' } }); // POST /teams/:id/members - params + body const member = await api.addTeamMember.call({ params: { teamId: '123' }, body: { userId: '456', role: 'member' } });
PUT/PATCH Requests
typescript// PUT /users/:id - params + body const user = await api.updateUser.call({ params: { id: '123' }, body: { name: 'Updated Name', email: 'new@example.com' } });
DELETE Requests
typescript// DELETE /users/:id - params only const result = await api.deleteUser.call({ params: { id: '123' } });
Request Options
Adding Headers
typescriptconst user = await api.getUser .headers({ 'X-Custom-Header': 'value' }) .call({ params: { id: '123' } });
Adding Cookies
typescriptconst user = await api.getUser .cookies({ session: 'abc123' }) .call({ params: { id: '123' } });
Fetch Options (Next.js)
typescript// With Next.js caching options const user = await api.getUser .fetchOptions({ next: { revalidate: 60 } }) .call({ params: { id: '123' } }); // Disable caching const freshUser = await api.getUser .fetchOptions({ cache: 'no-store' }) .call({ params: { id: '123' } });
Chaining Options
typescriptconst user = await api.getUser .headers({ 'X-Request-ID': 'req-123' }) .cookies({ session: 'abc' }) .fetchOptions({ next: { revalidate: 60 } }) .call({ params: { id: '123' } });
Client Configuration
Creating the Client
typescript// src/lib/api.ts import { createApi } from '@spfn/core/client'; import type { AppRouter } from '@/server/router'; export const api = createApi<AppRouter>({ // Base URL for RPC endpoints (default: '/api/rpc') baseUrl: '/api/rpc', // Default headers for all requests headers: { 'X-App-Version': '1.0.0' }, // Request timeout in milliseconds (default: 30000) timeout: 30000, // Enable debug logging debug: process.env.NODE_ENV === 'development', });
Request/Response Interceptors
typescriptexport const api = createApi<AppRouter>({ // Intercept all requests onRequest: async (url, init) => { // Add auth token const token = await getAuthToken(); if (token) { init.headers = { ...init.headers, Authorization: `Bearer ${token}` }; } return init; }, // Intercept all responses onResponse: async (response, body) => { // Log all responses console.log(`${response.status}: ${response.url}`); return { response, body }; }, });
Error Handling
The client throws ApiError for non-2xx responses:
typescriptimport { ApiError } from '@spfn/core/client'; try { const user = await api.createUser.call({ body: { name: 'John', email: 'invalid' } }); } catch (error) { if (error instanceof ApiError) { console.error('Status:', error.status); // 400 console.error('Message:', error.message); // "Validation failed" console.error('Details:', error.data); // { field: 'email', ... } // Handle specific error types switch (error.status) { case 400: console.error('Validation error'); break; case 401: console.error('Unauthorized'); break; case 404: console.error('Not found'); break; default: console.error('Server error'); } } }
Custom Error Classes
Register custom error classes to receive typed errors:
typescriptimport { NotFoundError, ValidationError } from '@spfn/core/errors'; export const api = createApi<AppRouter>({ errorRegistry: { NotFoundError, ValidationError, }, }); // Now errors are typed correctly try { await api.getUser.call({ params: { id: 'invalid' } }); } catch (error) { if (error instanceof NotFoundError) { console.error('User not found:', error.resource); } }
Best Practices
1. Create a Single API Instance
typescript// src/lib/api.ts import { createApi } from '@spfn/core/client'; import type { AppRouter } from '@/server/router'; // Single instance for the entire app export const api = createApi<AppRouter>({ debug: process.env.NODE_ENV === 'development', });
2. Use Server Components When Possible
typescript// ✅ Good: Server Component - no client JS export default async function UsersPage() { const users = await api.getUsers.call({ query: { page: 1 } }); return <UserList users={users} />; } // ⚠️ Avoid: Client-side fetching when not needed 'use client'; export default function UsersPage() { const [users, setUsers] = useState([]); useEffect(() => { /* fetch... */ }, []); return <UserList users={users} />; }
3. Type the Response
typescript// Types are automatically inferred const user = await api.getUser.call({ params: { id: '1' } }); // ^? { id: string; name: string; email: string } // Use in components with full type safety function UserCard({ userId }: { userId: string }) { const [user, setUser] = useState<Awaited<ReturnType<typeof api.getUser.call>>>(); // ... }
4. Handle Errors Consistently
typescript// Create a wrapper for consistent error handling export async function safeApiCall<T>( fn: () => Promise<T>, fallback: T ): Promise<T> { try { return await fn(); } catch (error) { console.error('API call failed:', error); return fallback; } } // Usage const users = await safeApiCall( () => api.getUsers.call({ query: { page: 1 } }), { items: [], total: 0 } );
Next: Middleware
Learn how to create and manage middleware for authentication, logging, and more.