Building API Endpoints

Every route in SvelteKit that has a +page.svelte file is a page route — it renders HTML for humans. But some URLs shouldn’t return HTML. They should return JSON for a mobile app, binary data for a file download, or an empty 204 response for a webhook acknowledgement. This is what +server.js (or +server.ts) is for.

A +server.js file in a route directory makes that URL respond to HTTP methods directly, without rendering any HTML. The same filesystem-based routing applies: src/routes/api/posts/+server.ts becomes the handler for GET /api/posts, POST /api/posts, and so on. You export a named function for each HTTP method you want to handle.

This means SvelteKit is a complete full-stack framework. Your backend API lives in src/routes/api/, your pages live in src/routes/, and they share code from src/lib/. One codebase, one deployment, no CORS configuration between your frontend and backend.

Co-locating API logic

The ability to co-locate API endpoints with your frontend is a significant productivity gain — no separate backend repository, no CORS headers to configure, shared TypeScript types between client and server. The constraint: +server.js files should contain HTTP handler logic, not business logic. Keep domain logic in $lib/server/ where it can be shared and tested independently.


When to Use +server.js

Use +server.js when you need to:

  • Return JSON data — For client-side fetch requests
  • Build a REST API — CRUD operations for your data
  • Handle webhooks — Receive data from external services
  • Generate files — PDFs, images, CSV exports
  • Proxy requests — Forward requests to external APIs

HTTP Method Handlers

In a +server.js file, you export functions named after HTTP methods:

// src/routes/api/posts/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

// Handle GET requests
export const GET: RequestHandler = async () => {
	const posts = await db.posts.findMany()
	return json(posts)
}

// Handle POST requests
export const POST: RequestHandler = async ({ request }) => {
	const data = await request.json()
	const post = await db.posts.create({ data })
	return json(post, { status: 201 })
}

// Handle PUT requests
export const PUT: RequestHandler = async ({ request }) => {
	const data = await request.json()
	const post = await db.posts.update({
		where: { id: data.id },
		data
	})
	return json(post)
}

// Handle DELETE requests
export const DELETE: RequestHandler = async ({ request }) => {
	const { id } = await request.json()
	await db.posts.delete({ where: { id } })
	return new Response(null, { status: 204 })
}

The json Helper

API endpoints must return Response objects, not plain values. The json helper is a convenience wrapper that serializes your data with JSON.stringify, sets the Content-Type: application/json header automatically, and wraps the result in a proper Response. You could construct this manually, but json() makes it one line:

import { json } from '@sveltejs/kit'

// Equivalent to: new Response(JSON.stringify({ message: 'Hello' }), {
//   headers: { 'Content-Type': 'application/json' }
// })
return json({ message: 'Hello' })

// With a custom status code
return json({ error: 'Not found' }, { status: 404 })

// With additional headers
return json(data, {
	status: 200,
	headers: {
		'Cache-Control': 'max-age=3600'
	}
})

When you need to return something that isn’t JSON — a file, plain text, a stream — you construct the Response directly, as shown in the “Returning Other Response Types” section below.


Request Handler Parameters

Each handler receives useful parameters:

export const POST: RequestHandler = async ({
	request, // The Request object
	params, // Route parameters
	url, // URL object with searchParams
	cookies, // Read/write cookies
	locals, // Data from hooks
	fetch, // Server-side fetch
	platform, // Platform-specific context
	setHeaders // Set response headers
}) => {
	// Your handler logic
}

Reading Request Data

JSON Body

export const POST: RequestHandler = async ({ request }) => {
	const data = await request.json()
	// data is the parsed JSON body
	return json({ received: data })
}

Form Data

export const POST: RequestHandler = async ({ request }) => {
	const formData = await request.formData()
	const name = formData.get('name')
	const email = formData.get('email')

	return json({ name, email })
}

URL Search Parameters

export const GET: RequestHandler = async ({ url }) => {
	const page = Number(url.searchParams.get('page')) || 1
	const limit = Number(url.searchParams.get('limit')) || 10

	const posts = await db.posts.findMany({
		skip: (page - 1) * limit,
		take: limit
	})

	return json({ posts, page, limit })
}

Dynamic API Routes

Just like pages, API routes can have dynamic parameters:

// src/routes/api/posts/[id]/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async ({ params }) => {
	const post = await db.posts.findUnique({
		where: { id: params.id }
	})

	if (!post) {
		error(404, 'Post not found')
	}

	return json(post)
}

export const DELETE: RequestHandler = async ({ params }) => {
	await db.posts.delete({
		where: { id: params.id }
	})

	return new Response(null, { status: 204 })
}

Error Handling

Use the error helper to return error responses:

import { json, error } from '@sveltejs/kit'

export const GET: RequestHandler = async ({ params }) => {
	const post = await db.posts.findUnique({
		where: { id: params.id }
	})

	if (!post) {
		error(404, 'Post not found')
	}

	return json(post)
}

For more control, return a Response directly:

export const POST: RequestHandler = async ({ request }) => {
	try {
		const data = await request.json()
		// ... process data
		return json({ success: true })
	} catch (e) {
		return json({ error: 'Invalid JSON' }, { status: 400 })
	}
}

Authentication in API Routes

Access the user from locals (set by your hooks):

// src/routes/api/user/profile/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async ({ locals }) => {
	if (!locals.user) {
		error(401, 'Unauthorized')
	}

	return json({
		id: locals.user.id,
		name: locals.user.name,
		email: locals.user.email
	})
}

export const PUT: RequestHandler = async ({ locals, request }) => {
	if (!locals.user) {
		error(401, 'Unauthorized')
	}

	const data = await request.json()

	const updated = await db.users.update({
		where: { id: locals.user.id },
		data: {
			name: data.name,
			bio: data.bio
		}
	})

	return json(updated)
}

Returning Other Response Types

Plain Text

export const GET: RequestHandler = async () => {
	return new Response('Hello, World!', {
		headers: {
			'Content-Type': 'text/plain'
		}
	})
}

HTML

export const GET: RequestHandler = async () => {
	const html = `
		<!DOCTYPE html>
		<html>
			<body><h1>Hello</h1></body>
		</html>
	`
	return new Response(html, {
		headers: {
			'Content-Type': 'text/html'
		}
	})
}

File Downloads

export const GET: RequestHandler = async () => {
	const csv = 'name,email\nJohn,john@example.com'

	return new Response(csv, {
		headers: {
			'Content-Type': 'text/csv',
			'Content-Disposition': 'attachment; filename="users.csv"'
		}
	})
}

Streaming Responses

export const GET: RequestHandler = async () => {
	const stream = new ReadableStream({
		start(controller) {
			controller.enqueue('Hello ')
			setTimeout(() => {
				controller.enqueue('World!')
				controller.close()
			}, 1000)
		}
	})

	return new Response(stream, {
		headers: {
			'Content-Type': 'text/plain'
		}
	})
}

Consuming API Routes

From Load Functions

// src/routes/blog/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ fetch }) => {
	const response = await fetch('/api/posts')
	return {
		posts: await response.json()
	}
}

From Components

<script lang="ts">
	let posts = $state([])

	async function loadPosts() {
		const response = await fetch('/api/posts')
		posts = await response.json()
	}

	async function createPost(title: string) {
		const response = await fetch('/api/posts', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ title })
		})

		if (response.ok) {
			const newPost = await response.json()
			posts = [...posts, newPost]
		}
	}
</script>

CORS Configuration

For APIs consumed by external domains:

export const GET: RequestHandler = async () => {
	return json(data, {
		headers: {
			'Access-Control-Allow-Origin': '*',
			'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
		}
	})
}

// Handle preflight requests
export const OPTIONS: RequestHandler = async () => {
	return new Response(null, {
		headers: {
			'Access-Control-Allow-Origin': '*',
			'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
			'Access-Control-Allow-Headers': 'Content-Type, Authorization'
		}
	})
}

When to Use +server.js vs Form Actions

Both +server.js and form actions (in +page.server.js) handle mutations on the server. The distinction comes down to who is calling them and what interface you want.

Form actions are the right choice when the mutation comes from an HTML form in your SvelteKit app. They work with JavaScript disabled, they support progressive enhancement with use:enhance, and they automatically invalidate and re-render the current page. They are a first-class SvelteKit pattern for HTML-form-based interactions.

+server.js is the right choice when you need a general-purpose HTTP endpoint: mobile apps calling your API, webhooks from Stripe or GitHub, programmatic fetch calls from components, or any consumer that isn’t an HTML form submission.

Use +server.js when…Use Form Actions when…
External clients need to call the URLThe caller is always your SvelteKit app
Returning non-HTML responses (JSON…)Handling an HTML form submission
Building a REST or RPC APIYou want progressive enhancement
Handling webhooks from external servicesYou want automatic page revalidation

Common Patterns

Newsletter Subscription

// src/routes/api/newsletter/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const POST: RequestHandler = async ({ request }) => {
	const { email } = await request.json()

	// Validate email
	if (!email || !email.includes('@')) {
		return json({ error: 'Invalid email' }, { status: 400 })
	}

	// Check if already subscribed
	const existing = await db.subscribers.findUnique({
		where: { email }
	})

	if (existing) {
		return json({ error: 'Already subscribed' }, { status: 409 })
	}

	// Subscribe
	await db.subscribers.create({
		data: { email }
	})

	return json({ success: true }, { status: 201 })
}

Health Check Endpoint

// src/routes/api/health/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async () => {
	const dbHealthy = await checkDatabase()

	return json(
		{
			status: dbHealthy ? 'healthy' : 'degraded',
			timestamp: new Date().toISOString(),
			version: '1.0.0'
		},
		{
			status: dbHealthy ? 200 : 503
		}
	)
}

Conclusion

The +server.js file transforms SvelteKit routes from purely page-rendering mechanisms into full API endpoints, enabling you to build RESTful APIs, webhooks, and data services directly within your application’s route structure. By exporting HTTP method handlers instead of page components, these files create endpoints that return JSON, files, or any response type you need.

This co-location of API logic with frontend code eliminates the traditional frontend/backend split for many applications, making full-stack development feel seamless and natural.

Mastering +server.js means understanding the full Web Fetch API surface—constructing Response objects, setting headers, handling streams, and implementing proper HTTP semantics. Combined with access to cookies for authentication, params for dynamic routing, and the ability to throw errors that cascade through SvelteKit’s error handling system, server endpoints provide everything needed to build production-grade APIs.

Whether building internal data fetching endpoints, public REST APIs, or webhook receivers, the +server.js pattern makes backend logic a natural extension of your routing system.

Key Takeaways

  • +server.js creates API endpoints by exporting HTTP method handlers (GET, POST, PUT, DELETE) instead of rendering pages
  • Return Response objects using helpers like json() for JSON responses, text() for plain text, or new Response() for custom types
  • Access route parameters via params object, URL search params via url.searchParams, and cookies for authentication
  • Use error() for structured errors that flow through SvelteKit’s error handling with proper HTTP status codes
  • Co-locate API logic with frontend - place endpoints alongside pages in your route structure for related functionality
  • Support any response type - JSON, text, files, streams, or custom MIME types via the Response constructor
  • Perfect for webhooks and integrations - receive POST requests from external services, validate signatures, process data server-side
  • Access server-only context via locals for request-scoped data, cookies for session management, and platform for deployment environment

See Also