Blog
SvelteKit Remote Functions vs tRPC: Simplifying Full-Stack APIs
Compare SvelteKit Remote Functions and tRPC for building type-safe APIs. Learn when to use each approach, their pros and cons, and practical implementation examples for modern web development.
The Evolution of Type-Safe APIs
In the modern web development landscape, building type-safe APIs that bridge client and server has become increasingly important. Two powerful solutions have emerged: SvelteKit Remote Functions and tRPC. Both promise to eliminate the type safety gap between frontend and backend, but they take fundamentally different approaches.
This guide will help you understand when to use each approach, their strengths and weaknesses, and how to implement them effectively in your projects.
Understanding the Problem Space
Before diving into solutions, let’s understand what we’re trying to solve:
The Traditional API Problem
// Server-side API endpoint
app.get('/api/users/:id', (req, res) => {
const user = getUser(req.params.id);
res.json(user);
});
// Client-side consumption
const response = await fetch('/api/users/123');
const user = await response.json();
// No type safety here! Issues with traditional approaches:
- No compile-time type checking
- Runtime errors only discovered at execution
- Manual type definitions that can drift
- No IntelliSense or autocomplete
- Refactoring becomes risky
The Promise of Type-Safe APIs
Both SvelteKit Remote Functions and tRPC solve these problems by providing:
- End-to-end type safety
- Compile-time error detection
- Automatic IntelliSense
- Refactoring safety
- Developer experience improvements
SvelteKit Remote Functions: The Framework-Native Approach
SvelteKit Remote Functions represent a framework-native solution that’s deeply integrated into the SvelteKit ecosystem.
What Are Remote Functions?
Remote Functions are a SvelteKit feature that allows you to call server-side functions directly from your client-side code, with full type safety and zero boilerplate.
Key Characteristics
- Framework-native: Built into SvelteKit
- Zero configuration: Works out of the box
- Automatic serialization: Handles data transformation
- Built-in validation: Uses Standard Schema validation
- SSR-friendly: Works seamlessly with server-side rendering
Basic Setup
1. Enable Remote Functions in your SvelteKit config:
// svelte.config.js
export default {
kit: {
experimental: {
remoteFunctions: true
}
}
}; 2. Create a remote function file with Drizzle:
// src/routes/api/users.remote.ts
import { query, form } from '$app/server';
import { z } from 'zod';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
// Query function for fetching users
export const getUsers = query(async () => {
return await db.select().from(users).orderBy(users.createdAt);
});
// Query function with parameters
export const getUserById = query(z.object({ id: z.string() }), async ({ id }) => {
const user = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!user[0]) {
throw new Error('User not found');
}
return user[0];
});
// Form function for mutations
export const createUser = form(async (formData) => {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const newUser = await db.insert(users).values({ name, email }).returning();
return newUser[0];
}); 3. Use in your SvelteKit components:
<!-- src/routes/users/+page.svelte -->
<script lang="ts">import { getUsers, getUserById, createUser } from "./api.remote";
let { params } = $props();
// Direct function calls with full type safety
const users = await getUsers();
const user = await getUserById({ id: params.id });
</script>
<h1>Users</h1>
<ul>
{#each users as user}
<li>{user.name} - {user.email}</li>
{/each}
</ul>
<form method="POST" action="?/createUser">
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Create User</button>
</form> Remote Function Types
SvelteKit Remote Functions come in four flavors:
1. Query Functions - For reading data:
export const getData = query(async () => {
return await fetchData();
}); 2. Form Functions - For mutations via forms:
export const updateData = form(async (formData) => {
const value = formData.get('value');
return await updateDatabase(value);
}); 3. Command Functions - For programmatic mutations:
export const deleteItem = command(z.object({ id: z.string() }), async ({ id }) => {
return await deleteFromDatabase(id);
}); 4. Prerender Functions - For static generation:
export const getStaticData = prerender(
z.string(),
async (slug) => {
return await getPostBySlug(slug);
},
{
inputs: () => ['post-1', 'post-2', 'post-3']
}
); tRPC with SvelteKit: The Type-Safe Solution
tRPC takes a different approach - it’s a mature, battle-tested library that provides excellent integration with SvelteKit through dedicated adapters and patterns.
What Is tRPC?
tRPC (TypeScript Remote Procedure Call) is a library that enables end-to-end typesafe APIs without code generation or schemas. It’s designed to work seamlessly with SvelteKit through the @trpc/server/adapters/fetch adapter.
Key Characteristics
- SvelteKit-native: Excellent integration with SvelteKit’s fetch adapter
- Protocol-based: Uses HTTP for communication
- Rich ecosystem: Extensive middleware and adapter support
- Mature: Well-established with large community
- Flexible: Highly customizable with Drizzle ORM support
Basic Setup with SvelteKit and Bun
Project Structure:
src/
├── lib/
│ ├── server/
│ │ └── db/
│ │ ├── index.ts
│ │ └── schema.ts
│ └── trpc/
│ ├── client.ts
│ ├── context.ts
│ ├── init.ts
│ ├── router.ts
│ └── routes/
│ └── users.ts
└── routes/
└── api/
└── trpc/
└── [...procedure]/
└── +server.ts 1. Install tRPC packages with Bun:
bun add drizzle-orm @trpc/server @trpc/client
bun add -d drizzle-kit 2. Set up Drizzle ORM with PostgreSQL:
// src/lib/server/db/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert; // src/lib/server/db/index.ts
import { drizzle } from 'drizzle-orm/bun-sql';
import { DATABASE_URL } from '$env/static/private';
import * as schema from './schema';
const db = drizzle(DATABASE_URL, { schema });
export { db }; 3. Create tRPC context and initialization:
// src/lib/trpc/context.ts
import type { RequestEvent } from '@sveltejs/kit';
export async function createContext(event: RequestEvent) {
return event;
}
export type Context = Awaited<ReturnType<typeof createContext>>; // src/lib/trpc/init.ts
import { initTRPC } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure; 4. Create the server router with Drizzle:
// src/lib/trpc/routes/users.ts
import { router, publicProcedure } from '../init';
import { z } from 'zod';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
export const usersRouter = router({
getAll: publicProcedure.query(async () => {
return await db.select().from(users).orderBy(users.createdAt);
}),
getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const user = await db.select().from(users).where(eq(users.id, input.id)).limit(1);
if (!user[0]) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return user[0];
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email')
})
)
.mutation(async ({ input }) => {
const newUser = await db.insert(users).values(input).returning();
return newUser[0];
})
}); // src/lib/trpc/router.ts
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { usersRouter } from './routes/users';
import { router } from './init';
export const appRouter = router({
users: usersRouter
});
export const createCaller = router.createCallerFactory(appRouter);
export type AppRouter = typeof appRouter;
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>; 5. Set up the SvelteKit API endpoint:
// src/routes/api/trpc/[...procedure]/+server.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createContext } from '$lib/trpc/context';
import type { RequestHandler } from './$types';
import { appRouter } from '$lib/trpc/router';
export const GET: RequestHandler = (event) => {
return fetchRequestHandler({
endpoint: '/api/trpc',
req: event.request,
router: appRouter,
createContext: () => createContext(event)
});
};
export const POST = GET; 6. Create the SvelteKit client:
// src/lib/trpc/client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { browser } from '$app/environment';
import type { AppRouter } from './router';
import { page } from '$app/stores';
function createTRPC() {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
// We use the absolute path on the server
url: browser ? '/api/trpc' : `${page.url.origin}/api/trpc`
})
]
});
}
let browserClient: ReturnType<typeof createTRPC>;
export function trpc() {
const client = browserClient ?? createTRPC();
if (browser) browserClient ??= client;
return client;
} 7. Usage Example:
// src/routes/users/+page.svelte
import { trpc } from '$lib/trpc/client';
import { onMount } from 'svelte';
import type { User } from '$lib/server/db/schema';
let users: User[] = [];
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
users = await trpc().users.getAll.query();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fetch users';
} finally {
loading = false;
}
});
async function createUser(event: Event) {
const formData = new FormData(event.target as HTMLFormElement);
const name = formData.get('name') as string;
const email = formData.get('email') as string;
try {
const newUser = await trpc().users.create.mutate({ name, email });
users = [...users, newUser];
// Reset form
(event.target as HTMLFormElement).reset();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create user';
}
} <!-- src/routes/users/+page.svelte -->
<h1>Users</h1>
{#if error}
<div class="error">{error}</div>
{/if}
{#if loading}
<p>Loading...</p>
{:else}
<ul>
{#each users as user}
<li>{user.name} - {user.email}</li>
{/each}
</ul>
{/if}
<form on:submit|preventDefault={createUser}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Create User</button>
</form>
<style>
.error {
color: red;
padding: 1rem;
background: #fee;
border-radius: 4px;
margin-bottom: 1rem;
}
</style> Detailed Comparison: When to Use Each Approach
| Use Case | SvelteKit Remote Functions | tRPC with SvelteKit |
|---|---|---|
| SvelteKit Projects | ✅ When you’re building a SvelteKit application ✅ When you want framework-native integration ✅ When you prefer minimal configuration | ✅ When you’re building a SvelteKit application ✅ When you want mature, battle-tested solutions ✅ When you need excellent TypeScript integration |
| Simple APIs | ✅ When your API needs are straightforward ✅ When you don’t need complex middleware ✅ When you want to get started quickly | ❌ Overkill for simple APIs |
| Complex Applications | ❌ Limited for complex requirements | ✅ When you need advanced middleware ✅ When you require sophisticated error handling ✅ When you need extensive customization |
| SSR-Heavy Applications | ✅ When server-side rendering is crucial ✅ When you need seamless hydration ✅ When you want optimal performance | ✅ Good SSR support but more setup required |
| Real-time Features | ❌ Limited real-time support | ✅ When you need WebSocket support ✅ When you require subscriptions ✅ When you need real-time updates |
Performance Comparison
| Aspect | SvelteKit Remote Functions | tRPC with SvelteKit |
|---|---|---|
| Bundle Size | ✅ Minimal bundle impact (built into SvelteKit) ✅ No additional client libraries ✅ Optimized for SvelteKit’s architecture | ⚠️ Additional client library (~15KB gzipped) ✅ Highly optimized for SvelteKit and database operations ✅ Excellent TypeScript integration with Drizzle |
| Runtime Performance | ✅ Excellent SSR performance ✅ Minimal client-side overhead ✅ Optimized for SvelteKit’s rendering | ✅ Excellent for complex data fetching with Drizzle ✅ Advanced caching capabilities ✅ Optimized for database-heavy applications ✅ Great performance with PostgreSQL |
Developer Experience Comparison
| Aspect | SvelteKit Remote Functions | tRPC with SvelteKit |
|---|---|---|
| Learning Curve | ✅ Easy to learn - follows SvelteKit patterns ✅ Minimal concepts - just functions ✅ Familiar syntax - standard async/await | ⚠️ Steeper learning curve - more concepts (tRPC) ⚠️ More setup required - routers, procedures, links ✅ Powerful features - once learned, very flexible ✅ Excellent database integration - type-safe with Drizzle ORM |
| Type Safety | ✅ End-to-end type safety ✅ Compile-time error detection ✅ IntelliSense support ✅ Refactoring safety | ✅ End-to-end type safety ✅ Compile-time error detection ✅ IntelliSense support ✅ Refactoring safety |
Error Handling
SvelteKit Remote Functions:
// Simple error handling
export const getUser = query(async (id: string) => {
try {
return await db.getUser(id);
} catch (error) {
throw new Error('User not found');
}
}); tRPC with SvelteKit:
// Advanced error handling with Drizzle ORM
export const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.select().from(users).where(eq(users.id, input.id)).limit(1);
if (!user[0]) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found'
});
}
return user[0];
}); Advanced Patterns and Best Practices
SvelteKit Remote Functions: Advanced Usage
1. Error Boundaries:
<script>
import { getUsers } from './api.remote';
</script>
<svelte:boundary><UserList /></svelte:boundary>
{#await getUsers()}
<p>Loading...</p>
{:then users}
<ul>
{#each users as user}
<li>{user.name}</li>
{/each}
</ul>
{:catch error}
<p>Error: {error.message}</p>
{/await} 2. Form Validation with Drizzle:
import { form } from '$app/server';
import { z } from 'zod';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18 or older')
});
export const createUser = form(async (formData) => {
const data = {
name: formData.get('name'),
email: formData.get('email'),
age: parseInt(formData.get('age') as string)
};
const validated = createUserSchema.parse(data);
const newUser = await db.insert(users).values(validated).returning();
return newUser[0];
}); tRPC: Advanced Patterns
1. Middleware:
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user
}
});
});
export const protectedProcedure = t.procedure.use(isAuthed); 2. Caching with Drizzle:
export const getUsers = publicProcedure
.query(async () => {
return await db.select().from(users).orderBy(users.createdAt);
})
.cache(60); // Cache for 60 seconds 3. Real-time Subscriptions:
export const onUserUpdate = publicProcedure.subscription(async () => {
return await db.subscribeToUserUpdates();
}); Real-World Examples
E-commerce Application
SvelteKit Remote Functions:
// src/routes/api/products.remote.ts
import { query, form } from '$app/server';
import { z } from 'zod';
import { db } from '$lib/server/db';
import { products, orders, orderItems } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const getProducts = query(async () => {
return await db.select().from(products).orderBy(products.createdAt);
});
export const getProduct = query(z.object({ id: z.string() }), async ({ id }) => {
const product = await db.select().from(products).where(eq(products.id, id)).limit(1);
if (!product[0]) {
throw new Error('Product not found');
}
return product[0];
});
export const createOrder = form(async (formData) => {
const items = JSON.parse(formData.get('items') as string);
return await db.transaction(async (tx) => {
const order = await tx.insert(orders).values({ status: 'pending' }).returning();
const orderItemsData = items.map((item: any) => ({
orderId: order[0].id,
productId: item.productId,
quantity: item.quantity
}));
await tx.insert(orderItems).values(orderItemsData);
return order[0];
});
}); tRPC with SvelteKit:
// src/lib/trpc/routes/products.ts
import { products, orders, orderItems } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
export const productsRouter = router({
getAll: publicProcedure.query(async () => {
return await db.select().from(products).orderBy(products.createdAt);
}),
getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const product = await db.select().from(products).where(eq(products.id, input.id)).limit(1);
if (!product[0]) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return product[0];
}),
createOrder: publicProcedure
.input(
z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number()
})
)
})
)
.mutation(async ({ input }) => {
// Use Drizzle transactions for complex operations
return await db.transaction(async (tx) => {
const order = await tx.insert(orders).values({ status: 'pending' }).returning();
const orderItemsData = input.items.map((item) => ({
orderId: order[0].id,
productId: item.productId,
quantity: item.quantity
}));
await tx.insert(orderItems).values(orderItemsData);
return order[0];
});
})
}); Future Outlook
SvelteKit Remote Functions
Current State:
- Experimental but stable
- Framework-native integration
- Growing adoption
Future Development:
- Likely to become stable feature
- Enhanced tooling support
- More advanced patterns
tRPC
Current State:
- Mature and battle-tested
- Large ecosystem
- Extensive documentation
Future Development:
- Continued framework support
- Enhanced performance optimizations
- More advanced features
Decision Matrix: Which Should You Choose?
Choose SvelteKit Remote Functions If:
- You’re building a SvelteKit application
- You want minimal setup and configuration
- You prefer framework-native solutions
- You have simple to moderate API needs
- You want excellent SSR performance
- You’re starting a new project
Choose tRPC with SvelteKit If:
- You’re building a SvelteKit application
- You have complex API requirements
- You need advanced middleware
- You want mature, battle-tested solutions
- You need real-time features
- You’re using Drizzle ORM with PostgreSQL
- You need extensive customization
- You want excellent TypeScript integration
Conclusion: The Right Tool for the Right Job
Both SvelteKit Remote Functions and tRPC are excellent solutions for building type-safe APIs, but they serve different use cases and preferences.
SvelteKit Remote Functions represent the future of framework-native API development - simple, integrated, and powerful. They’re perfect for SvelteKit applications where you want minimal complexity and maximum integration.
tRPC with SvelteKit offers a mature, flexible solution that works seamlessly with SvelteKit and Drizzle ORM. It’s ideal for complex applications that need advanced features, excellent database integration, and type safety.
The choice ultimately depends on your specific needs:
- Framework preference: SvelteKit native vs SvelteKit with tRPC
- Complexity requirements: Simple vs advanced with database integration
- Team expertise: Learning curve considerations (Remote Functions vs tRPC + Drizzle)
- Project scale: Small vs enterprise with database requirements
Remember: Both solutions eliminate the type safety gap between client and server, which is the most important benefit. Choose the one that fits your project’s architecture and team’s expertise.
Happy coding with type-safe APIs!