Common SvelteKit mistakes: practical tips and trade-offs
Building full-stack applications with SvelteKit feels natural—routes, server-side logic, and client code all live in one place. But that convenience comes with hidden complexity. We've seen teams ship bugs in production because they misunderstood how SvelteKit's data flow works, how server state behaves across concurrent requests, or what progressive enhancement really means. This article covers the most common SvelteKit mistakes we encounter and the trade-offs you need to understand to build reliable applications.
Importing private environment variables in client code
One of the quickest security leaks in SvelteKit happens when developers treat environment variables the same way they would in a traditional backend framework. SvelteKit's build process bundles client code into browser-readable JavaScript, so any private keys or secrets imported in component code will end up exposed.
Here's the mistake:
// +page.svelte - WRONG
<script>
import { PRIVATE_API_KEY } from '$env/static/private';
async function fetchData() {
const response = await fetch(`/api/data?key=${PRIVATE_API_KEY}`);
return response.json();
}
</script>That PRIVATE_API_KEY is now visible in your network tab and bundled JavaScript. Any user can inspect it.
The correct pattern: use +page.server.js or +server.js route files to access private secrets. These files run only on the server and can safely import from $env/static/private.
// +page.server.js - CORRECT
import { PRIVATE_API_KEY } from '$env/static/private';
export async function load() {
const response = await fetch('https://external-api.com/data', {
headers: {
'Authorization': `Bearer ${PRIVATE_API_KEY}`
}
});
const data = await response.json();
return { data };
}
// +page.svelte
<script>
export let data;
</script>
<p>{data.result}</p>If you need environment values in the browser, prefix them with PUBLIC_. SvelteKit strips these at build time and inlines only the public ones.
// .env
PUBLIC_API_URL=https://api.example.com
PRIVATE_API_KEY=secret123
// +page.svelte
<script>
import { PUBLIC_API_URL } from '$env/static/public';
// This is safe—only the public URL is included
const url = `${PUBLIC_API_URL}/endpoint`;
</script>Shared state across requests on the server
In traditional backend frameworks, you rarely make the mistake of storing request-specific data in module scope. But in SvelteKit, because server route files persist across requests, developers sometimes store data at the module level thinking it's request-isolated. It's not.
This is the trap:
// +server.js - WRONG
let requestCount = 0;
export async function GET() {
requestCount++;
return new Response(`Request #${requestCount}`);
}Every request increments the same counter. Two concurrent requests will interfere with each other. In high-traffic scenarios, you'll see race conditions and data bleeding between users.
The fix is straightforward: treat module-scope variables as shared infrastructure, not per-request data. Store request-specific values in the function scope or use SvelteKit's locals object through event hooks.
// hooks.server.js
export async function handle({ event, resolve }) {
event.locals.requestId = crypto.randomUUID();
event.locals.userId = null; // populated by middleware
return resolve(event);
}
// +server.js - CORRECT
export async function GET(event) {
const { requestId } = event.locals;
return new Response(`Request ID: ${requestId}`);
}If you need application-level caches (like database connection pools or in-memory stores), use module scope deliberately and make them thread-safe. Document the intent clearly.
// +server.js - SHARED BUT INTENTIONAL
import NodeCache from 'node-cache';
// This is intentionally shared across all requests
const cache = new NodeCache({ stdTTL: 600 });
export async function GET() {
const cached = cache.get('user-list');
if (cached) return new Response(JSON.stringify(cached));
const data = await fetchUsers();
cache.set('user-list', data);
return new Response(JSON.stringify(data));
}Ignoring progressive enhancement
SvelteKit makes it easy to build client-side interactive applications, but the framework also encourages progressive enhancement through form actions. Many teams skip this pattern and end up with applications that break without JavaScript or behave poorly on slow networks.
The mistake is treating SvelteKit like a single-page app framework where JavaScript is mandatory:
// +page.svelte - BRITTLE
<script>
let title = '';
async function handleSubmit() {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ title })
});
if (response.ok) {
title = '';
// Manual refresh of UI
}
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<input bind:value={title} />
<button type="submit">Save</button>
</form>This breaks if JavaScript fails to load. The form can't submit.
Use form actions instead:
// +page.server.js
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const title = formData.get('title');
// Save to database
await db.titles.create({ title });
return { success: true };
}
};
// +page.svelte
<script>
import { enhance } from '$app/forms';
let savedTitle = '';
function handleResult({ result }) {
if (result.type === 'success') {
savedTitle = 'Saved!';
}
}
</script>
<form method="POST" use:enhance={handleResult}>
<input name="title" />
<button type="submit">Save</button>
</form>
{#if savedTitle}
<p>{savedTitle}</p>
{/if}With this approach, the form works with JavaScript disabled. When JavaScript loads, use:enhance intercepts the submission and updates the UI smoothly. You get progressive enhancement for free.
Forgetting ORIGIN behind proxies
SvelteKit's ORIGIN environment variable tells the framework the public URL of your application. This matters for secure cookies, CSRF tokens, and redirects. When you deploy behind a proxy or load balancer, the origin SvelteKit infers (from the incoming request) might be wrong.
The scenario: your app runs on http://localhost:5173, but users access it via https://app.example.com through an Nginx reverse proxy.
// hooks.server.js - WRONG (without explicit ORIGIN)
export async function handle({ event, resolve }) {
// event.url.origin might be http://localhost:5173
// or the proxy's internal address, not app.example.com
const origin = event.url.origin;
return resolve(event);
}Set ORIGIN explicitly in your deployment environment:
// .env (or container/platform config)
ORIGIN=https://app.example.com
// hooks.server.js - CORRECT
import { ORIGIN } from '$env/static/private';
export async function handle({ event, resolve }) {
// Use the configured ORIGIN for secure operations
event.locals.origin = ORIGIN;
return resolve(event);
}This is critical for SvelteKit's built-in security. If ORIGIN is wrong, secure cookies won't work correctly, and redirects might leak users to unintended URLs. Most hosting platforms (Vercel, Netlify, Docker environments) let you set environment variables—use that feature.
Common mistakes summary
- Private keys in components: They end up in the browser bundle. Use server-only routes.
- Module-scope request data: It persists across requests and causes race conditions. Use event.locals or request scope.
- Skipping progressive enhancement: Your app breaks without JavaScript. Use form actions.
- Ignoring ORIGIN configuration: Proxies and load balancers break security features. Set ORIGIN explicitly.
Key takeaways
- Separate client code from server code mentally and physically. Private secrets belong only in
+server.jsand+page.server.js. - Treat module scope as shared application infrastructure, not per-request storage. Use
event.localsfor request-scoped data. - Progressive enhancement is a feature, not overhead. Form actions work without JavaScript and get enhanced with it.
- Always set
ORIGINexplicitly in production, especially behind proxies. Don't rely on inferred values. - Security leaks and race conditions from these mistakes are hard to debug in production. Catch them in code review by establishing team patterns early.






Comments
Loading comments…