Skip to content

Fix: SvelteKit Not Working — load Function Errors, Form Actions Failing, or SSR Data Not Available

FixDevs ·

Quick Answer

How to fix SvelteKit issues — load function data flow, +page.server.ts vs +page.ts, form actions with use:enhance, hooks.server.ts, SSR vs CSR mode, and common routing mistakes.

The Problem

Data from a load function isn’t available in the page component:

// +page.server.ts
export async function load() {
  return { users: await fetchUsers() };
}

// +page.svelte
<script>
  export let data;  // data.users is undefined
</script>

Or a form action fails silently:

// +page.server.ts
export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    // Validation error — but the page just reloads with no feedback
  }
};

Or SSR throws a reference error for browser APIs:

ReferenceError: window is not defined
    at +page.svelte:3

Or navigation to a route returns 404 despite the file existing:

GET /dashboard/settings → 404
# +page.svelte exists at src/routes/dashboard/settings/+page.svelte

Why This Happens

SvelteKit’s file-based routing has specific naming conventions and data-flow patterns:

  • +page.svelte receives data from load only as the data prop — the prop must be declared with export let data. Without that declaration, the data is passed but never received.
  • +page.server.ts vs +page.ts — server load functions run only on the server (can access databases, env vars). Universal load functions run on both server and client. Choosing the wrong one causes missing data or access errors.
  • Form actions only work with +page.server.ts — actions can’t be defined in +page.ts. Defining actions in a client-side file causes a runtime error or is silently ignored.
  • SSR runs in Node.jswindow, document, localStorage, and other browser globals don’t exist during server-side rendering. Code that accesses them must be guarded or moved to lifecycle hooks.

Fix 1: Wire Up load Functions and Page Data Correctly

src/routes/
├── +page.svelte         # Root page
├── +page.ts             # Universal load (runs server + client)
├── +page.server.ts      # Server-only load (DB access, secrets)
├── +layout.svelte       # Layout wrapping all child routes
├── +layout.server.ts    # Layout load — data available to all children
├── +error.svelte        # Error boundary for this route
└── dashboard/
    ├── +page.svelte
    ├── +page.server.ts
    └── settings/
        └── +page.svelte
// src/routes/users/+page.server.ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';

export const load: PageServerLoad = async ({ params, locals, url }) => {
  const users = await db.user.findMany();

  return {
    users,
    // Everything returned here is available as data.users in the page
  };
};
<!-- src/routes/users/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  // This prop MUST be declared — SvelteKit passes data here
  export let data: PageData;
  // data.users is now available
</script>

<ul>
  {#each data.users as user}
    <li>{user.name}</li>
  {/each}
</ul>

Parent layout data is automatically merged into child page data:

// src/routes/+layout.server.ts — runs for every page
export const load: LayoutServerLoad = async ({ locals }) => {
  return {
    currentUser: locals.user,  // Available as data.currentUser in all pages
  };
};
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  export let data;  // Includes both layout data and page data
  // data.currentUser  ← from layout load
  // data.dashboardStats  ← from this page's load
</script>

Streaming data with defer for slow operations:

// src/routes/dashboard/+page.server.ts
export const load: PageServerLoad = async () => {
  // Fast data — awaited immediately
  const user = await getUser();

  // Slow data — streamed to the client
  const statsPromise = getExpensiveStats();

  return {
    user,
    streamed: {
      stats: statsPromise,  // NOT awaited — streamed
    },
  };
};
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  export let data;
</script>

<p>Welcome, {data.user.name}</p>

{#await data.streamed.stats}
  <p>Loading stats...</p>
{:then stats}
  <p>Total users: {stats.totalUsers}</p>
{/await}

Fix 2: Use Form Actions Correctly

Form actions handle POST requests — they must live in +page.server.ts:

// src/routes/users/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  return { users: await db.user.findMany() };
};

export const actions: Actions = {
  // Default action — triggered by <form method="POST">
  default: async ({ request }) => {
    const data = await request.formData();
    const name = data.get('name') as string;
    const email = data.get('email') as string;

    if (!name || !email) {
      return fail(400, {
        error: 'Name and email are required',
        name,  // Return values to repopulate the form
        email,
      });
    }

    try {
      await db.user.create({ data: { name, email } });
    } catch (e) {
      return fail(500, { error: 'Failed to create user' });
    }

    redirect(303, '/users');  // PRG pattern
  },

  // Named action — triggered by <form action="?/delete" method="POST">
  delete: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id') as string;

    await db.user.delete({ where: { id } });
    return { success: true };
  },
};
<!-- src/routes/users/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData, PageData } from './$types';

  export let data: PageData;
  export let form: ActionData;  // Contains action return value
</script>

{#if form?.error}
  <p class="error">{form.error}</p>
{/if}

<!-- use:enhance prevents full page reload -->
<form method="POST" use:enhance>
  <input name="name" value={form?.name ?? ''} />
  <input name="email" value={form?.email ?? ''} />
  <button type="submit">Create User</button>
</form>

<!-- Named action -->
<form action="?/delete" method="POST" use:enhance>
  <input type="hidden" name="id" value={user.id} />
  <button type="submit">Delete</button>
</form>

Progressive enhancement with use:enhance callbacks:

<script lang="ts">
  import { enhance } from '$app/forms';

  let loading = false;

  function handleSubmit() {
    loading = true;

    return async ({ result, update }) => {
      loading = false;

      if (result.type === 'success') {
        // Custom success handling
        await update();
      } else if (result.type === 'failure') {
        // Handle validation errors
        await update({ reset: false });  // Don't reset form
      }
    };
  }
</script>

<form method="POST" use:enhance={handleSubmit}>
  <button disabled={loading}>
    {loading ? 'Saving...' : 'Save'}
  </button>
</form>

Fix 3: Fix SSR and Browser API Errors

Any code that accesses browser-only APIs must be protected from running during SSR:

<!-- WRONG — window is undefined on server -->
<script>
  const width = window.innerWidth;
</script>

<!-- CORRECT — use onMount (runs client-side only) -->
<script>
  import { onMount } from 'svelte';

  let width = 0;

  onMount(() => {
    width = window.innerWidth;

    const handler = () => { width = window.innerWidth; };
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  });
</script>

Check the environment with browser:

<script>
  import { browser } from '$app/environment';

  // Runs during module evaluation — check browser first
  const storage = browser ? localStorage : null;

  $: if (browser) {
    document.title = `My App - ${pageTitle}`;
  }
</script>

Disable SSR entirely for a route:

// src/routes/dashboard/+page.ts
export const ssr = false;  // This page is client-side only (SPA mode)
export const prerender = false;

Or disable SSR globally:

// src/routes/+layout.ts
export const ssr = false;  // Entire app is SPA mode

Conditional import of browser-only libraries:

<script lang="ts">
  import { onMount } from 'svelte';

  let Chart: any;

  onMount(async () => {
    // Import runs only on client — chart.js uses window
    const module = await import('chart.js');
    Chart = module.Chart;
    Chart.register(...module.registerables);

    // Initialize chart
    const ctx = document.getElementById('myChart') as HTMLCanvasElement;
    new Chart(ctx, { type: 'bar', data: chartData });
  });
</script>

<canvas id="myChart"></canvas>

Fix 4: Configure hooks.server.ts for Auth and Middleware

hooks.server.ts runs before every request — use it for authentication, logging, and request modification:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { verifyToken } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
  // Run before every request

  // 1. Attach auth context
  const token = event.cookies.get('session');
  if (token) {
    try {
      event.locals.user = await verifyToken(token);
    } catch {
      event.cookies.delete('session', { path: '/' });
    }
  }

  // 2. Protect routes
  const protectedPaths = ['/dashboard', '/settings', '/api/user'];
  const isProtected = protectedPaths.some(p => event.url.pathname.startsWith(p));

  if (isProtected && !event.locals.user) {
    redirect(303, `/login?redirectTo=${event.url.pathname}`);
  }

  // 3. Resolve the request
  const response = await resolve(event, {
    // Transform HTML output (optional)
    transformPageChunk: ({ html }) => html.replace('%lang%', 'en'),
  });

  // 4. Modify the response
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
};

// Handle server errors
export function handleError({ error, event }) {
  console.error('Server error:', error, 'on:', event.url.pathname);
  return {
    message: 'Internal server error',
    code: (error as any)?.code ?? 'UNKNOWN',
  };
}

Declare locals type:

// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string; role: string } | null;
    }
    interface PageData {
      // Add any shared page data here
    }
    interface Error {
      code: string;
    }
  }
}

export {};

Fix 5: Fix Routing and Navigation Issues

// SvelteKit routing — filename conventions
src/routes/
├── +page.svelte              → /
├── about/
│   └── +page.svelte          → /about
├── blog/
│   ├── +page.svelte          → /blog
│   └── [slug]/
│       └── +page.svelte      → /blog/:slug
├── (auth)/                   # Route group — no URL segment
│   ├── login/
│   │   └── +page.svelte      → /login
│   └── register/
│       └── +page.svelte      → /register
├── api/
│   └── users/
│       └── +server.ts        → GET/POST /api/users
└── [[optional]]/
    └── +page.svelte          → / and /:optional

API routes with +server.ts:

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

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

  const page = Number(url.searchParams.get('page') ?? '1');
  const users = await db.user.findMany({
    skip: (page - 1) * 20,
    take: 20,
  });

  return json(users);
};

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

  const body = await request.json();
  const user = await db.user.create({ data: body });

  return json(user, { status: 201 });
};

Programmatic navigation:

<script lang="ts">
  import { goto, invalidate, invalidateAll } from '$app/navigation';
  import { page } from '$app/stores';

  // Current URL info
  $: currentPath = $page.url.pathname;
  $: params = $page.params;

  async function handleAction() {
    await someServerCall();

    // Invalidate specific load function
    await invalidate('/api/users');

    // Or re-run all load functions
    await invalidateAll();

    // Navigate
    goto('/dashboard', { replaceState: true });
  }
</script>

Fix 6: Environment Variables and Configuration

// .env
DATABASE_URL=postgresql://localhost/mydb   # Private — server only
PUBLIC_API_BASE=https://api.example.com   # Public — available in browser

// src/routes/+page.server.ts — server-only env vars
import { DATABASE_URL } from '$env/static/private';
import { PUBLIC_API_BASE } from '$env/static/public';

// src/routes/+page.svelte — public env vars only
import { PUBLIC_API_BASE } from '$env/static/public';
// Importing static/private in a .svelte file causes a build error

// Dynamic env (read at runtime, not build time)
import { env } from '$env/dynamic/private';
const dbUrl = env.DATABASE_URL;

SvelteKit svelte.config.js essentials:

// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),  // Enables TypeScript, PostCSS, etc.

  kit: {
    adapter: adapter(),  // Auto-detect deployment platform

    // Path aliases
    alias: {
      $components: 'src/components',
      $lib: 'src/lib',
    },

    // CSRF protection (enabled by default)
    csrf: {
      checkOrigin: true,
    },
  },
};

export default config;

Still Not Working?

data prop is undefined even though load returns data — the most common cause is a missing export let data in the <script> tag. Without export, Svelte treats data as an internal variable, not a prop. Another cause: the file is named +page.js instead of +page.ts and TypeScript isn’t configured, causing a silent import failure.

Form action returns fail() but form prop is null — the form prop in +page.svelte is only populated after a form submission. On initial page load it’s null. Always check {#if form} or {form?.error} before accessing properties. If using use:enhance, ensure it’s imported from $app/forms and applied correctly — without it, the form does a full page reload, which may clear the form state depending on your redirect logic.

redirect() throws instead of redirectingredirect() and error() from @sveltejs/kit work by throwing. If you call redirect() inside a try/catch block without re-throwing the value, the redirect is swallowed:

// WRONG
try {
  await riskyOperation();
  redirect(303, '/success');
} catch (e) {
  return fail(500, { error: 'Failed' });  // Catches the redirect!
}

// CORRECT — check if the thrown value is a redirect/error first
import { isRedirect } from '@sveltejs/kit';
try {
  await riskyOperation();
  redirect(303, '/success');
} catch (e) {
  if (isRedirect(e)) throw e;  // Re-throw redirects
  return fail(500, { error: 'Failed' });
}

For related Svelte issues, see Fix: Svelte Store Not Updating and Fix: Vite HMR Connection Lost.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles