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.

SvelteKit Remote Functions vs tRPC: Simplifying Full-Stack APIs

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 CaseSvelteKit Remote FunctionstRPC 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

AspectSvelteKit Remote FunctionstRPC 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

AspectSvelteKit Remote FunctionstRPC 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!