Published 12/5/2025 · 6 min read
Lesson 17: API Routes
SvelteKit isn’t just for pages. You can build full API endpoints right alongside your frontend. Perfect for JSON APIs, webhooks, and more.
Creating an Endpoint
Create +server.js (or .ts) in any route:
// src/routes/api/hello/+server.js
export function GET() {
return new Response("Hello, world!");
}
Visit /api/hello and you’ll see “Hello, world!”.
Returning JSON
// src/routes/api/users/+server.js
import { json } from "@sveltejs/kit";
export function GET() {
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
return json(users);
}
The json() helper sets the correct content type and serializes your data.
HTTP Methods
Export a function for each method you want to handle:
// src/routes/api/posts/+server.js
import { json } from "@sveltejs/kit";
import { db } from "$lib/server/database";
// GET /api/posts
export async function GET() {
const posts = await db.getAllPosts();
return json(posts);
}
// POST /api/posts
export async function POST({ request }) {
const body = await request.json();
const post = await db.createPost(body);
return json(post, { status: 201 });
}
Supported methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.
Dynamic API Routes
Just like pages, use square brackets:
// src/routes/api/posts/[id]/+server.js
import { json, error } from "@sveltejs/kit";
import { db } from "$lib/server/database";
export async function GET({ params }) {
const post = await db.getPost(params.id);
if (!post) {
throw error(404, "Post not found");
}
return json(post);
}
export async function PUT({ params, request }) {
const body = await request.json();
const post = await db.updatePost(params.id, body);
if (!post) {
throw error(404, "Post not found");
}
return json(post);
}
export async function DELETE({ params }) {
const deleted = await db.deletePost(params.id);
if (!deleted) {
throw error(404, "Post not found");
}
return new Response(null, { status: 204 });
}
Request Data
Access different parts of the request:
export async function POST({ request, url, cookies, locals }) {
// JSON body
const body = await request.json();
// Form data
const formData = await request.formData();
// Query parameters: /api/search?q=hello
const query = url.searchParams.get("q");
// Headers
const authHeader = request.headers.get("authorization");
// Cookies
const session = cookies.get("session");
// Data from hooks (like user from auth)
const user = locals.user;
// ...
}
Response Options
Control response headers and status:
import { json } from "@sveltejs/kit";
export function GET() {
return json(
{ data: "hello" },
{
status: 200,
headers: {
"cache-control": "max-age=60",
"x-custom-header": "value",
},
}
);
}
Or build responses manually:
export function GET() {
return new Response(JSON.stringify({ data: "hello" }), {
status: 200,
headers: {
"content-type": "application/json",
"cache-control": "max-age=60",
},
});
}
Streaming Responses
For large data or real-time updates:
export function GET() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue("First chunk\n");
setTimeout(() => {
controller.enqueue("Second chunk\n");
controller.close();
}, 1000);
},
});
return new Response(stream, {
headers: {
"content-type": "text/plain",
},
});
}
Error Handling
Throw errors to return error responses:
import { error, json } from "@sveltejs/kit";
export async function GET({ params }) {
const item = await db.getItem(params.id);
if (!item) {
throw error(404, {
message: "Item not found",
code: "ITEM_NOT_FOUND",
});
}
return json(item);
}
Or return error responses directly:
export async function POST({ request }) {
try {
const body = await request.json();
// ...
} catch (e) {
return json({ error: "Invalid JSON" }, { status: 400 });
}
}
Authentication
Protect endpoints using hooks or checking in the handler:
// src/routes/api/admin/+server.js
import { error, json } from "@sveltejs/kit";
export async function GET({ locals }) {
if (!locals.user) {
throw error(401, "Unauthorized");
}
if (!locals.user.isAdmin) {
throw error(403, "Forbidden");
}
// ... admin-only data
return json({ secret: "admin stuff" });
}
The locals.user would be set in a hook (like hooks.server.js).
CORS Headers
Handle cross-origin requests:
// src/routes/api/public/+server.js
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
export function OPTIONS() {
return new Response(null, { headers: corsHeaders });
}
export function GET() {
return json({ data: "publicly accessible" }, { headers: corsHeaders });
}
File Downloads
Serve files:
// src/routes/api/download/[filename]/+server.js
import { error } from "@sveltejs/kit";
import fs from "fs/promises";
import path from "path";
export async function GET({ params }) {
const filePath = path.join("uploads", params.filename);
try {
const file = await fs.readFile(filePath);
return new Response(file, {
headers: {
"content-type": "application/octet-stream",
"content-disposition": `attachment; filename="${params.filename}"`,
},
});
} catch (e) {
throw error(404, "File not found");
}
}
Webhooks
Handle incoming webhooks:
// src/routes/api/webhooks/stripe/+server.js
import { error, json } from "@sveltejs/kit";
import { STRIPE_WEBHOOK_SECRET } from "$env/static/private";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST({ request }) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_WEBHOOK_SECRET
);
} catch (err) {
throw error(400, `Webhook Error: ${err.message}`);
}
switch (event.type) {
case "payment_intent.succeeded":
await handlePaymentSuccess(event.data.object);
break;
case "payment_intent.failed":
await handlePaymentFailure(event.data.object);
break;
}
return json({ received: true });
}
Comparing to Nuxt
Nuxt server routes:
// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
return { posts: [] };
});
// server/api/posts.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event);
return { created: body };
});
SvelteKit:
// src/routes/api/posts/+server.js
import { json } from "@sveltejs/kit";
export function GET() {
return json({ posts: [] });
}
export async function POST({ request }) {
const body = await request.json();
return json({ created: body });
}
Nuxt uses file naming for methods. SvelteKit uses exports. Both work well.
Practical Example: REST API
A complete CRUD API:
// src/routes/api/todos/+server.js
import { json } from "@sveltejs/kit";
import { db } from "$lib/server/database";
export async function GET({ url }) {
const completed = url.searchParams.get("completed");
let todos = await db.getAllTodos();
if (completed !== null) {
const isCompleted = completed === "true";
todos = todos.filter((t) => t.completed === isCompleted);
}
return json(todos);
}
export async function POST({ request }) {
const { text } = await request.json();
if (!text?.trim()) {
return json({ error: "Text is required" }, { status: 400 });
}
const todo = await db.createTodo({ text, completed: false });
return json(todo, { status: 201 });
}
// src/routes/api/todos/[id]/+server.js
import { json, error } from "@sveltejs/kit";
import { db } from "$lib/server/database";
export async function GET({ params }) {
const todo = await db.getTodo(params.id);
if (!todo) throw error(404, "Todo not found");
return json(todo);
}
export async function PATCH({ params, request }) {
const updates = await request.json();
const todo = await db.updateTodo(params.id, updates);
if (!todo) throw error(404, "Todo not found");
return json(todo);
}
export async function DELETE({ params }) {
const deleted = await db.deleteTodo(params.id);
if (!deleted) throw error(404, "Todo not found");
return new Response(null, { status: 204 });
}
Key Takeaways
+server.jscreates API endpoints- Export functions for HTTP methods:
GET,POST,PUT,DELETE - Use
json()helper for JSON responses - Access params, query strings, headers, cookies, body
- Throw
error()for error responses - Use
localsfor auth data from hooks - Can stream responses and handle files
Related Articles
- x402 with SvelteKit: Full-Stack Example
Build a complete SvelteKit application with x402 payments - wallet connection, protected routes, and automatic payment handling.
- Your First x402 Server: Pay-Per-Request API
Build an Express API that requires Solana USDC payments. Return 402, verify payments, serve content.
- What is x402? The HTTP Status Code That Changes Everything
HTTP 402 'Payment Required' finally has a real implementation. Learn how x402 enables pay-per-request APIs and micropayments on the web.