Building robust backend logic in SvelteKit doesn't require a separate server—you can use +server.ts endpoints to handle everything from API calls to webhook processing. We've learned that mastering API routes in SvelteKit: real-world lessons and examples matters because it's where your frontend meets real data, and getting the patterns right saves hours of debugging later.
What are API routes and why they matter
In SvelteKit, +server.ts files act as your API layer. Instead of spinning up Express or Fastify separately, you colocate your endpoint logic alongside your routes. A file like src/routes/api/users/+server.ts becomes an endpoint at GET /api/users. This colocation keeps related code together and lets you lean on SvelteKit's routing system for both pages and APIs.
We've seen teams struggle when they treat +server.ts like a quick escape hatch for "server code" without thinking about validation, error handling, or response shape. That's where most issues emerge. The endpoint layer is where contracts get enforced—it's not optional infrastructure.
Define handlers — Export GET, POST and other methods
Each +server.ts file exports named functions for HTTP verbs: GET, POST, PUT, DELETE, PATCH. These are your handler signatures. A basic example:
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ params }) => {
const userId = params.id;
// Fetch user from database
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) {
return json({ error: 'Not found' }, { status: 404 });
}
return json(user);
};
Notice the RequestHandler type—it keeps us honest about what we're receiving and returning. The function receives a RequestEvent object with params, url, request, cookies, and locals. In production, we've found that using these correctly prevents entire classes of bugs.
For POST, you'll often need to read the request body:
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
// Process body
return json({ created: true }, { status: 201 });
};
A pattern we use everywhere: always validate before you touch your database. Don't assume the body shape is correct.
Return JSON — json() helper for responses
SvelteKit provides a json() helper that sets the correct Content-Type header and serializes your data. It accepts a second argument for response options—status codes, headers, and cookies:
import { json } from '@sveltejs/kit';
// Simple response
return json({ message: 'Success' });
// With status code
return json({ error: 'Invalid input' }, { status: 400 });
// With custom headers
return json(data, {
status: 200,
headers: { 'X-Custom-Header': 'value' }
});
In production e-commerce systems, we've built response envelopes like:
interface ApiResponse {
success: boolean;
data?: T;
error?: string;
timestamp: number;
}
export const GET: RequestHandler = async () => {
try {
const items = await db.items.findMany();
return json>({
success: true,
data: items,
timestamp: Date.now()
});
} catch (err) {
return json>(
{
success: false,
error: 'Database error',
timestamp: Date.now()
},
{ status: 500 }
);
}
};
This envelope gives your frontend a predictable shape and makes error handling consistent across your app.
Validation — Parse bodies with Zod
This is where API routes often fail in the real world. You receive arbitrary JSON and assume it's shaped correctly. Zod (or similar libraries) enforce contracts at the boundary:
import { z } from 'zod';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional()
});
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const validated = CreateUserSchema.parse(body);
// Now 'validated' is type-safe
const user = await db.users.create({
data: validated
});
return json(user, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) {
return json(
{ error: 'Validation failed', issues: err.issues },
{ status: 400 }
);
}
return json({ error: 'Server error' }, { status: 500 });
}
};
We've seen teams skip validation and then spend weeks chasing subtle state corruption bugs. The few extra lines up front are an insurance policy.
Use for — Webhooks, APIs and file downloads
Webhooks: Incoming webhooks from Stripe, GitHub, or other services hit your +server.ts endpoints. You validate the signature, process the event, and return 200 quickly:
import crypto from 'crypto';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
export const POST: RequestHandler = async ({ request }) => {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
// Verify signature
const secret = process.env.STRIPE_WEBHOOK_SECRET;
const hash = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
if (hash !== signature) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const event = JSON.parse(body);
// Dispatch to background job queue
await queue.enqueue('process_webhook', event);
return json({ received: true });
};
File downloads: Return binary data with appropriate headers:
import { text } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async () => {
const csv = generateCSVReport();
return text(csv, {
headers: {
'Content-Disposition': 'attachment; filename="report.csv"'
}
});
};
RESTful APIs: Your standard CRUD operations. A route like src/routes/api/orders/[id]/+server.ts handles /api/orders/123:
export const GET: RequestHandler = async ({ params }) => {
const order = await db.orders.findUnique({
where: { id: params.id }
});
return json(order || {}, { status: order ? 200 : 404 });
};
export const PATCH: RequestHandler = async ({ params, request }) => {
const updates = await request.json();
const order = await db.orders.update({
where: { id: params.id },
data: updates
});
return json(order);
};
Common mistakes
- Trusting the request body shape: Always validate with Zod or similar. A single
undefinedin a critical field can cascade through your system. - Ignoring error boundaries: Every database call, API call, or file operation can fail. Wrap in try-catch and return sensible error responses. Don't let unhandled exceptions leak to the client.
- Forgetting authentication:
localsfrom hooks gives you user context, but you must check it. Don't assume someone is authenticated just because they hit your endpoint. - Blocking long operations: File uploads, heavy computations, or external API calls block the response. Offload to a queue (Bull, BullMQ, or a job runner) and return immediately.
- Missing CORS headers: If your frontend is on a different domain, you need
cors()middleware or explicit headers. SvelteKit handles same-origin cases automatically, but cross-origin requests need explicit allowance. - Not versioning your API: As your schema evolves, old clients break. Consider
/api/v1/usersto give yourself upgrade headroom.
Key takeaways
+server.tsfiles export named handler functions (GET,POST, etc.) for HTTP methods—no separate framework needed.- Use the
json()helper with appropriate status codes and headers to shape responses consistently. - Validate all incoming data with Zod before touching your database or business logic; catch validation errors and return
400responses. - Deploy webhooks, file downloads, and REST APIs through
+server.ts—route colocation keeps related code together. - Always wrap database and external calls in try-catch; return predictable error responses rather than leaking stack traces.
- Check authentication in your handlers via
locals; don't assume users are authenticated. - For heavy operations, queue jobs asynchronously and return
202 Acceptedto avoid blocking responses.






Comments
Loading comments…