Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 093 / 100

Redirects in SvelteKit for Svelte & SvelteKit developers

Move users the right way.

Expona Admin8 min read

Redirects are one of those foundational HTTP concepts that feels simple until you're standing in production wondering why users keep hitting the wrong page. In SvelteKit, how you orchestrate redirects—and where you do it—directly impacts user experience, SEO, and the reliability of your forms. We'll walk through the practical patterns that work across e-commerce checkouts, SaaS onboarding flows, and authentication handshakes.

The POST-Redirect-GET pattern with 303

After a user submits a form via POST, the web's golden rule is to redirect them somewhere safe using a 303 status code. This tells the browser: "The request succeeded; go fetch this new URL with a GET request." Without it, users who refresh the page after form submission see browser warnings about resubmitting data, or worse, duplicate actions trigger.

In SvelteKit, your form action typically looks like this:

typescript
import { redirect } from '@sveltejs/kit';

export const actions = {
  default: async ({ request, locals }) => {
    const data = await request.formData();
    const email = data.get('email');

    // Validate and save
    if (!email) {
      return { error: 'Email is required' };
    }

    await saveSubscriber(email, locals.userId);

    // Redirect with 303 (See Other)
    throw redirect(303, '/subscribe-success');
  }
};

That throw redirect(303, ...) is key. The 303 status code tells the browser: "This was a successful form submission; fetch the next page with GET." When users refresh, they just refresh the success page—no duplicate subscription.

We use 303 specifically because it mandates a GET request on the redirect target, preventing accidental resubmission. It's also what the HTTP spec recommends for form handling across frameworks and languages, so it's a safe, portable choice.

Permanent redirects: 301 vs. 308

When you move content permanently—a page renamed, a domain migration, or a route consolidation—you need a different tool. This is where 301 and 308 come in.

301 Moved Permanently is the older standard. Search engines follow it and update their indexes. However, some older clients may downgrade a POST request to GET on a 301 redirect, which can lose form data.

308 Permanent Redirect is newer and stricter: it preserves the HTTP method. A POST stays a POST, a GET stays a GET. If you're permanently moving an API endpoint or a form handler, 308 is safer.

In practice, use 301 for public content moves (blogs, documentation) where you're not concerned about preserving POST. Use 308 for APIs or form endpoints where method preservation matters. Here's how both look:

typescript
// For a renamed blog post page
export const load = async ({ params }) => {
  if (params.slug === 'old-post-name') {
    throw redirect(301, '/blog/new-post-name');
  }
  // ... load post
};

// For a relocated API or form endpoint
export const POST = async ({ request }) => {
  throw redirect(308, '/api/v2/process-payment');
};

Search engines treat 301 and 308 similarly for indexing purposes, so either works for SEO. The main difference is method preservation and client behavior. For user-facing pages, 301 is conventional. For backend endpoints and APIs, 308 is technically correct.

Site-wide redirect logic in hooks

Not all redirects belong in individual routes. If you need to enforce a redirect rule across your entire app—authentication flows, locale-based routing, or maintenance mode—put it in src/hooks.server.ts.

A common pattern is redirecting unauthenticated users away from protected routes:

typescript
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const session = await event.locals.getSession();

  // Redirect to login if accessing a protected route without auth
  if (event.url.pathname.startsWith('/dashboard') && !session) {
    throw redirect(303, `/login?redirect=${event.url.pathname}`);
  }

  // Redirect logged-in users away from auth pages
  if (event.url.pathname.startsWith('/login') && session) {
    throw redirect(303, '/dashboard');
  }

  return resolve(event);
};

Notice we preserve the intended destination in a query parameter. After login succeeds, the login page can redirect users back to where they wanted to go:

typescript
export const actions = {
  login: async ({ request, url }) => {
    const data = await request.formData();
    // ... authenticate user

    const redirectTo = url.searchParams.get('redirect') || '/dashboard';
    throw redirect(303, redirectTo);
  }
};

Hooks run on every request before routes load, making them ideal for app-wide policies. You can also use them for:

  • Redirecting HTTP to HTTPS (though usually handled at the server level)
  • Enforcing locale prefixes (e.g., /en/page, /es/page)
  • Maintenance mode redirects
  • Beta feature gates based on user properties

The key is keeping hook logic lightweight. Complex business logic still belongs in routes; hooks should handle structural concerns.

Avoiding redirect loops

A redirect loop happens when page A redirects to B, and B redirects back to A, creating an infinite cycle. Browsers stop after a few redirects and show an error, but the user experience is terrible.

Common culprits:

  • Bidirectional redirects: /old redirects to /new, and /new redirects back to /old.
  • Conditional redirects gone wrong: A route checks a condition and redirects, but the redirect target still meets that condition.
  • Auth hooks with stale session logic: The hook redirects to login, but the login check redirects back to the original route.

Here's a problematic example:

typescript
// DON'T DO THIS
export const load = async ({ url }) => {
  if (!url.searchParams.has('tracking_id')) {
    throw redirect(303, `${url.pathname}?tracking_id=${generateId()}`);
  }
};

Every page load adds a new tracking_id and redirects again. Even with the parameter present, the redirect fires and creates a loop.

Fix it by checking if the parameter already exists:

typescript
export const load = async ({ url, depends }) => {
  depends('tracking:init');

  if (!url.searchParams.has('tracking_id')) {
    const newUrl = new URL(url);
    newUrl.searchParams.set('tracking_id', generateId());
    throw redirect(303, newUrl.toString());
  }

  return {
    trackingId: url.searchParams.get('tracking_id')
  };
};

For authentication hooks, be explicit about what triggers a redirect and test both authenticated and unauthenticated paths:

typescript
// Only redirect if BOTH conditions are true
if (event.url.pathname === '/login' && session) {
  throw redirect(303, '/dashboard');
}

// Use exact path matching, not startsWith, if checking auth pages
if (!session && !PUBLIC_ROUTES.includes(event.url.pathname)) {
  throw redirect(303, '/login');
}

Where PUBLIC_ROUTES is an array like ['/login', '/signup', '/forgot-password', '/']. This prevents a loop: login page is public, so unauthenticated users can access it without being redirected away.

Common mistakes

Using redirect() instead of throw redirect(): The function must be thrown to interrupt the current handler. If you just call it, SvelteKit won't process it. Always use throw redirect(...).

Redirecting in page components: Page logic runs after initial render. Use +page.server.ts or +page.ts load functions, or hooks. Never redirect inside +page.svelte directly.

Wrong status code for the wrong reason: After a form POST, always use 303. Don't use 302 (Found, temporary) or 307 (Temporary Redirect) for form success—they don't have the same semantics. Use 301/308 only for permanent structural changes, not for every navigation.

Forgetting to return from a load function after redirect: Once you throw redirect, the function stops. But if you're conditionally redirecting, ensure all code paths are intentional.

typescript
// Okay: conditional redirect, then load data
export const load = async ({ params }) => {
  if (params.id === 'special') {
    throw redirect(303, '/special-page');
  }
  return await loadNormalData(params.id);
};

// Risky: data loading continues even if redirect throws
// (Actually safe because throw exits, but unclear intent)
export const load = async ({ params }) => {
  if (params.id === 'special') {
    throw redirect(303, '/special-page');
  }
  // This never runs if redirect is thrown
  return data;
};

Ignoring query parameters in redirect targets: If the original request had query params you want to preserve, build the full URL carefully:

typescript
const newUrl = new URL('/new-path', `http://${event.request.headers.get('host')}`);
newUrl.search = event.url.search; // Preserve query string
throw redirect(303, newUrl.toString());

Key takeaways

  • Use throw redirect(303, ...) after form submissions to prevent resubmission on refresh.
  • Use 301 for public permanent content moves; use 308 for permanent API/endpoint moves to preserve HTTP method.
  • Enforce app-wide redirect rules in src/hooks.server.ts for authentication, localization, and structural concerns.
  • Prevent redirect loops by being explicit about conditions and testing both authenticated and unauthenticated flows.
  • Always throw redirect; never just call it. Status code selection matters—303 for POST success, 301/308 for permanent moves.
  • Preserve query parameters and the user's intended destination when redirecting, especially in multi-step flows like login and onboarding.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles