SvelteKit redirects
Aug 24, 2026 SvelteKit
redirect() from @sveltejs/kit is how a SvelteKit server sends someone to another URL. It throws, which looks like a crash the first time it lands in a debugger. SvelteKit catches that throw and answers with a 3xx and a Location header.
If you return redirect(...), TypeScript already knows that is dead code. If you wrap it in try/catch, the user never moves. Those are the first two bugs people hit with this helper.
The SvelteKit tutorial already calls redirect(303, '/links') after a delete. This post puts the same helper in load, in an action, in hooks.server.ts, and in a +server.ts file, and picks the status on purpose.
Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib. The SvelteKit 3 migration guide lists those renames. Kit 3 also refuses redirect() to another origin unless you pass { external: true }. Paths like /about do not need that.
We will end up with these files:
src/hooks.server.ts
src/routes/
├── +layout.svelte
├── +page.svelte
├── about/
│ └── +page.svelte
├── about-us/
│ └── +server.ts
├── account/
│ ├── +page.server.ts
│ └── +page.svelte
├── contact/
│ ├── +page.server.ts
│ ├── +page.svelte
│ └── thanks/
│ └── +page.svelte
└── login/
├── +page.server.ts
└── +page.svelte Table of Contents
Step 1: create the project
Reuse the shop from routing if you have it. Otherwise install Node 18 or newer and run the Svelte CLI:
npx sv create shop
cd shop
npm install
npm run dev Pick TypeScript. Skip the add-ons.
Open http://localhost:5173. Vite is the dev server.
Replace src/routes/+layout.svelte:
<script lang="ts">
let { children } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
<a href="/account">Account</a>
</nav>
{@render children()} Replace src/routes/+page.svelte:
<h1>Northside Hardware</h1>
<p>
<a href="/about-us">/about-us</a> and <a href="/hours">/hours</a> still
resolve. They redirect to About.
</p> Create src/routes/about/+page.svelte:
<h1>About</h1>
<p>Open Monday to Saturday, 9 to 18.</p> Step 2: a moved URL
The old marketing page lived at /about-us. /about is the URL you want indexed. A client-side goto will not tell a crawler that. You want a real HTTP redirect.
A +server.ts file that only exports GET is enough. There is no page to render. Create src/routes/about-us/+server.ts:
import { redirect } from '@sveltejs/kit';
export function GET() {
redirect(308, '/about');
} Visit http://localhost:5173/about-us. The address bar should become /about.
308 is the permanent redirect that keeps POST as POST. 301 is older. I still see 301 pasted into new apps. Some clients will turn that POST into a GET on the new URL. If the old path only ever received GET, you will not notice. The first form that posts to /about-us will.
redirect() throws, so any line after it is dead. TypeScript types the function as never for that reason.
Step 3: redirect after a form POST
If an action succeeds and you stay on the same URL, a refresh resubmits the POST. That is how you get duplicate contact rows. POST-redirect-GET is the fix. Answer the POST with a redirect, then the browser loads the next page with GET.
Create src/routes/contact/+page.server.ts:
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const message = String(data.get('message') ?? '').trim();
if (!message) {
return fail(400, { message, missing: true });
}
redirect(303, '/contact/thanks');
}
}; 303 See Other means GET this other URL next. Use it after POST. The tutorial’s delete action uses the same status. PUT and DELETE belong here too if you ever send those from a form.
fail() returns a value. redirect() throws. In one action, validate first, return fail(...) on bad input, and redirect only after the work succeeded.
Create src/routes/contact/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<h1>Contact</h1>
<form method="POST" use:enhance>
<label>
Message
<textarea name="message" required>{form?.message ?? ''}</textarea>
</label>
{#if form?.missing}
<p>Write a message before sending.</p>
{/if}
<button type="submit">Send</button>
</form> Create src/routes/contact/thanks/+page.svelte:
<h1>Thanks</h1>
<p>We got the message.</p> Submit an empty message. If the browser blocks you, strip required in the inspector. You stay on /contact with the error. Submit real text. You land on /contact/thanks. Refresh the thanks page. The form does not fire again.
Nothing is stored. The 303 is the whole example. Wire a database later the same way the tutorial writes data/links.json.
Step 4: gate a page in load
load in +page.server.ts runs on the server before the page renders. If this user should not see the page, redirect from there. They never get the HTML or the data. A check in onMount is too late. The payload already left the server.
This login is a toy. A real session id in an HttpOnly cookie is the cookie auth post.
Create src/routes/login/+page.server.ts:
import { dev } from '$app/environment';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
function nextPath(value: string | null): string {
if (!value) return '/account';
if (!value.startsWith('/') || value.startsWith('//')) return '/account';
return value;
}
export const actions: Actions = {
default: async ({ cookies, request, url }) => {
const data = await request.formData();
const password = String(data.get('password') ?? '');
if (password !== 'demo') {
return fail(400, { message: 'Wrong password' });
}
cookies.set('staff', '1', {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: 60 * 60
});
redirect(303, nextPath(url.searchParams.get('next')));
}
}; nextPath is the open-redirect check. startsWith('/') feels like it closes the hole. It does not. //evil.example still starts with /, and the browser treats it as https://evil.example. The cookie auth post has the startsWith('/') check. Reject // as well. Then encode the path when you put it in a query string, which the account load does next.
Create src/routes/login/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<h1>Staff login</h1>
<form method="POST" use:enhance>
<label>
Password
<input name="password" type="password" required />
</label>
{#if form?.message}
<p>{form.message}</p>
{/if}
<button type="submit">Log in</button>
</form> Create src/routes/account/+page.server.ts:
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = ({ cookies, url }) => {
if (cookies.get('staff') !== '1') {
const next = encodeURIComponent(url.pathname + url.search);
redirect(307, `/login?next=${next}`);
}
};
export const actions: Actions = {
logout: async ({ cookies }) => {
cookies.delete('staff', { path: '/' });
redirect(303, '/');
}
}; 307 Temporary Redirect keeps the method. The account page is loaded with GET, so 307 is a GET to /login. The load docs use 307 for this. After the login POST, go back with 303. Mixing those two statuses is deliberate. 307 on the GET, 303 on the POST.
Create src/routes/account/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
</script>
<h1>Staff account</h1>
<p>You are in.</p>
<form method="POST" action="?/logout" use:enhance>
<button type="submit">Log out</button>
</form> Open /account while logged out. The URL becomes /login?next=%2Faccount. Password is demo. After submit you are on /account. Log out and you are on /.
The cookie value '1' is not a session. Anyone who can set that cookie is logged in. Use the cookie auth post before this faces a network.
Step 5: redirect in hooks
hooks.server.ts runs on every request, pages and +server.ts included. That is the right place when several old paths should move, or when the rule is not tied to one folder.
Create src/hooks.server.ts:
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname === '/hours') {
redirect(308, '/about');
}
return resolve(event);
}; Visit /hours. You should end on /about.
In SvelteKit 3 the Handle type lives in @sveltejs/kit/hooks. The redirect() call is the same.
A layout load that already gates /account does not need a second copy of that check in handle. One check next to the page is easier to read. Use hooks for rules that apply to many URLs, or to endpoints that layouts do not wrap.
Status codes
SvelteKit will send any 3xx you pass. The useful ones are 303, 307, and 308.
After a form POST, use 303 so the follow-up request is GET. After a load that decides “not this URL, not permanently,” use 307 so the method stays. After a URL that has moved for good, use 308.
301 and 302 still work. Some clients rewrite POST to GET. I would not depend on that. 304 is not a redirect. It means use the cached copy. Passing it to redirect() is a type-legal way to confuse yourself.
redirect() vs goto() vs <a>
goto from $app/navigation is client-side navigation. It does not send a 3xx. A crawler never sees it. Neither does a browser with JavaScript off.
An <a href> is the right default when you already know the destination. goto belongs in a click handler when the destination is computed after the click. redirect() belongs on the server when this URL should not render, JavaScript or not.
For another site, Kit 2 lets you pass the full URL to redirect(). Kit 3 needs the opt-in:
import { redirect } from '@sveltejs/kit';
redirect(307, 'https://example.com', { external: true }); javascript: URLs stay blocked even with external: true. Pass an array of origins instead of true if you want an allowlist. From the browser, assign window.location. goto is the wrong tool for leaving the origin.
Things that trip people up
The try/catch is the one that actually hurts. redirect() throws. A handler that logs error and returns a 500 swallows the navigation. If the await has to live in try, put redirect() in there too and re-throw with isRedirect:
import { fail, isRedirect, redirect } from '@sveltejs/kit';
try {
await save();
redirect(303, '/done');
} catch (error) {
if (isRedirect(error)) throw error;
return fail(500, { message: 'Save failed' });
} If save() is the only thing that can fail, call redirect() after the try block and skip isRedirect.
A streamed load that already returned a promise and started flushing HTML cannot change the status line. Redirect before you return.
An image or file GET that calls redirect() toward /login is a different bug. The browser tries to decode the login HTML as pixels. Return error(401) from that handler, as in serving files behind auth, and keep redirect() on page routes.
A layout load that sends guests to /login, plus a login load that sends signed-in users to /account, is fine when the conditions are opposites. If both fire for the same person, the browser stops after too many hops. That is a redirect loop. Log event.url.pathname in handle while you chase it.
Prerender plus a cookie check stores one outcome at build time. A live login gate needs a server at request time.
What to add next
Replace the '1' cookie with a session id using HttpOnly cookie auth. The load redirect stays. The cookie value changes.
The SvelteKit tutorial is the same 303 after a form, with a JSON file behind it.