Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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. The most important thing to internalize is that there are two runtime contexts and each file extension binds to exactly one of them. +page.server.ts runs only on the server, sees secrets, and can hit databases. +page.ts runs on both server and client and must therefore avoid anything that wouldn’t survive serialization to JSON.

The second pillar is layout precedence. SvelteKit composes +layout.svelte and +layout.server.ts from the root down. Every page automatically inherits all of its ancestor layouts’ loaded data — but it does not inherit their actions. Actions live on the +page.server.ts that owns the URL, full stop. A common bug is putting actions in +layout.server.ts thinking it will apply to all children; SvelteKit silently ignores it.

The third pillar is Vite plugin order, which only matters when you start adding third-party integrations. sveltekit() must come before any plugin that emits virtual modules consumed by SvelteKit (unocss/vite, MDX/MDSvex plugins, image processors). If you see “Cannot read property of undefined” deep inside SvelteKit’s build, plugin order is the first thing to check.

  • +page.svelte receives data from load only as the data prop — the prop must be declared with export let data (Svelte 4) or let { data } = $props() (Svelte 5).
  • +page.server.ts vs +page.ts — server load functions run only on the server. Universal load functions run on both server and client.
  • Form actions only work with +page.server.ts — actions can’t be defined in +page.ts or +layout.server.ts.
  • SSR runs in Node.jswindow, document, localStorage, and other browser globals don’t exist during server-side rendering.

Diagnostic Timeline

A senior dev’s first guess for a SvelteKit data bug is “check your load function.” That’s where the bug usually isn’t. Here’s the order that actually finds things:

Minute 0 — Print the data on the server. Add console.log('load result:', { users }) at the end of the load function. If you see the data in the terminal but the component still shows undefined, the load works — the problem is downstream, in how the page consumes data.

Minute 3 — Check server-only vs universal load. Open the actual filename. If it’s +page.ts but it imports from $lib/server/db, the build will fail with “server-only module imported into client.” If it’s +page.server.ts but you’re trying to access the result inside an onMount, the data was already serialized and is on data — not via a separate fetch.

Minute 7 — Check +page vs +layout precedence. When data shows up on one route but not a sibling, check whether you defined load in +layout.server.ts (inherited by all children) vs +page.server.ts (specific to that route). Layout data is merged into the page’s data prop; if a child page’s load returns { users: [] }, it shadows the layout’s users.

Minute 10 — Check $types import. A telltale of a stale build is data typed as any. import type { PageData } from './$types'; must be resolvable. If .svelte-kit/types/ is stale (after renaming files), run pnpm exec svelte-kit sync to regenerate them.

Minute 13 — Check Vite plugin order in vite.config.ts. sveltekit() must come first or at least before MDSvex, UnoCSS, and image processors. If you see errors like “Cannot read property of undefined (reading ‘imports’)” during build, swap orders and rebuild.

Minute 16 — Check use:enhance for actions. If your form submits but form is always null, you either forgot import { enhance } from '$app/forms' or you defined actions in +layout.server.ts. The form prop only populates after a POST that matches an action on the same route.

Minute 19 — Check hooks.server.ts for thrown redirects. If a route 404s “sometimes,” a hook may be calling redirect() inside a try/catch that swallows it. Wrap with if (isRedirect(e)) throw e; before the catch handles other errors.

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, isRedirect } 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 and Vite plugin order:

// 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;
// vite.config.ts — plugin order matters
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import UnoCSS from 'unocss/vite';

export default defineConfig({
  plugins: [
    sveltekit(),     // Must come first
    UnoCSS(),        // Other plugins after
  ],
});

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.

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' });
}

Universal load runs twice on navigation+page.ts runs once on the server during SSR and again on the client during hydration. This is intentional for hydration consistency, but if your load makes a third-party API call with a rate limit, you’ll burn the quota. Move the call to +page.server.ts so it only runs server-side, then return the data to the client.

invalidateAll() doesn’t refetch one specific load function — invalidation is keyed on the URLs your load functions touched (fetch() calls register as dependencies). If your load only reads from a non-fetch source (DB query, env var), it has no dependencies and won’t re-run on invalidate(url). Call depends('app:users') inside the load and then invalidate('app:users') to invalidate it explicitly.

Vite plugin order breaks the build — if you add unocss/vite, MDSvex, or imagetools before sveltekit(), the build can fail with cryptic errors. The sveltekit() plugin must come first in the plugins array. After fixing the order, run pnpm exec svelte-kit sync to regenerate .svelte-kit/types/.

For related Svelte issues, see Fix: Svelte Store Not Updating, Fix: Vite HMR Connection Lost, Fix: Svelte 5 Runes Not Working, and Fix: Vite Failed to Resolve Import.

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