im

SvelteKit SSR forms explained

Learn the basics of working with SSR forms in SvelteKit

I took the time to convert all of the examples for my Svelte Forms book to SvelteKit. Everything went surprisingly smooth except for a few minor import issues. SvelteKit is the SSR-first framework and if you want your clientside imports to work you either have to wrap them in the onMount hook or explicitly turn off SSR for that page.

SSR has its use cases, but it also makes things more complicated. I couldn't resist the urge to learn more how SvelteKit deals with forms in SSR mode. Below are my findings and lessons learned.

Why SSR?#

My personal first rule of SSR: if your app doesn't need SEO, don't use SSR.

Let me unpack this.

First, if you use SSR it means the app needs some kind of server to render the pages, serverless or not. This can add some substantial server costs. It might be a luxury problem and with in your budget, but still something to keep in mind.

Second, SSR adds additional layer of complexity to your app. Say what you want, but it is a fact. Do you really need or want that?

But wouldn't app's bundle size increase if I turn off SSR? It's a valid question and the answer is - NO. Or at least that's what I think. SvelteKit is good at code splitting and will only load JS and assets when you visit the page.

You can turn off SSR for the whole app, but it's not SvelteKit's default behaviour.

Progressive enhancement and graceful degradation#

Why might you want to use SSR? Back in the early days of the web when Ajax call were just invented some smart developers came up with two great concepts - progressive enhancement and graceful degradation. To me they are both equal in that way that they both accomplish the same goal. Progressive enhancement starts with the basic HTML and gradually adds capabilities to the page depending on what features browser supports. Graceful degradation is the same, but starts assuming that browser supports everything and then gracefully degrades to bare minimum of the features that the browser supports.

You might ask what that has to do with SSR? To me personally it means that if the JavaScript is broken on the page your app should still work and the users should still be able to submit the existing forms. You might think to yourself "that doesn't apply to me, I have tests," but the web is too unpredictable. Your tests is no guarantee even if they pass. Always assume the worse. I am not talking about becoming a total pessimist, but about programming defensively.

Create an new login form#

For our SSR for example we will use a simple login form, because you know, everyone can relate to one and have probably used one at some point in their life.

The example is built on top of SvelteKit's demo starter project. Go here to learn how to create one.

SIDENOTE: the code in all examples is naïve and simple. The main point is to illustrate and explain the core flows. It's happy path all the way!

src/routes/login/index.svelte
<h2>Login</h2>
<div>
<form action="/login" method="post">
<div>
<label>
<span>Email</span>
<input type="text" name="email" placeholder="name@company.com" />
</label>
</div>
<div>
<label>
<span>Password</span>
<input type="password" name="password" placeholder="*******" />
</label>
</div>
<div>
<button type="submit">Login</button>
<button type="reset">Reset</button>
</div>
</form>
</div>

SvelteKit uses a file based router. Because we put the index page in the login folder under routes you can access it at /login in your browser. If you submit the form the page will just reload itself because we have nothing in place to catch it. We need a SvelteKit endpoint to process the form.

Create an API endpoint#

Simply explained, endpoints in SvelteKit are exported functions with naming conventions. If your endpoint file exports a function with get, post, put, del names SvelteKit will map them to corresponding HTTP methods. Unlike Svelte code, they live in plain JavaScript or TypeScript files under the routes folder.

To process the login form we only need to support the HTTP POST method. I like to keep my code and its responsibilities co-located therefore we will create our endpoint file in the same login folder as our login page.

src/routes/login/index.js

export const post = async ({ request, url }) => {
const form = await request.formData();
const payload = Object.fromEntries(form);

console.log(payload);

const endpoint = new URL(`http://${url.host}/login`);

return {
headers: { Location: endpoint.toString() },
status: 302
}
}

In order to keep things simple we need to start simple. Earlier when we submitted the form there was no API handler to catch it and that's why the page just reloaded itself.

We now have an API handler that can process the form's POST request. If you look at the code you might ask what's going on and why we need a redirect?

You see, SvelteKit is not your traditional web framework like Rails or Django where the templates are tightly coupled with code. There is no way for us to return a SvelteKit page from the SvelteKit API endpoint. Because of that we have to redirect the user back to the login page.

You also need to parse the form body in your handler. It's your responsibility and SvelteKit won't do it for you.

The handler function takes a SvelteKit request as its only argument and it contains a lot of useful information we can leverage to make smart decisions. Right now host and body are the only ones we are interested in.

You might ask why we need to create a new URL object and not just hardcode the path? There is a good reason for it that we will talk about later.

So what does this endpoint handler do? Not much yet, but if you try to submit a form you should see the form values printed in the server console. It's a start.

Handle errors and success messages#

If you have large hands you might hit a few wrong characters when entering the password. We should be able to notify our users if something went wrong or if the login was successful. Unfortunately there is no easy way to do it in SvelteKit because endpoints are decoupled from the pages, but there is a hack for it. We can leverage URL query parameters to our advantage.

Remember that we created an URL object in the example earlier and I told you that there was a reason for it? If you look at the code below you will understand why.

src/routes/login/index.js
export const post = async ({ request, url }) => {
const form = await request.formData();
const payload = Object.fromEntries(form);

const endpoint = new URL(`http://${url.host}/login`);

if (
payload.username == "jane@example.com"
&& payload.password == "qwerty"
) {
endpoint.searchParams.append('success', true);
} else {
endpoint.searchParams.append('error', 'Wrong credentials')
}

return {
headers: { Location: endpoint.toString() },
status: 302
}
}

We are leveraging URL object's search params property to augment our redirect URL with useful information.

You next question is probably how to present this information to our end users. We can use SvelteKit's load function to parse that information on the serverside and pass it to our login page instance.

Load function is central when dealing with SSR in SvelteKit and has many different use cases. If our example we are only interested in the query parameters.

src/routes/login/index.svelte
<script context="module">
export async function load({ url }) {
const query = url.searchParams || {};

if (query.has('error')) {
return {
props: {
error: query.get('error')
}
};
}

if (query.has('success')) {
return {
props: {
success: query.get('success') === 'true'
}
};
}

// you have to return an empty object in case none
// of the if statements match
return {};
}
</script>

<script>

export let error = undefined;
export let success = false;
</script>

<h2>Login</h2>

<div>
{#if success}
<p class="success">You are successfuly logged in</p>
{/if}

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

<form action="/login" method="post">
...
</form>
</div>

The code shouldn't be too complex to understand. The important thing is that our module props name we return have matching exported variables in page's script block. In our case it's success and error.

One important thing to remember is to always return something back in the load function. If all of our if statements fall through and we don't return an empty object you will get a blank page back.

We have improved our flow and added meaningful user feedback, but I don't feel that comfortable with the solution. If you get an error page back you can change the message by tampering with the query parameters. This is not a big problem in our case, but what if we have logic that depends on boolean variables, like success? If you get this wrong it can lead to unwanted concequences.

It would be much better if you could pass an object to your page internally instead of relying on query parameters. I am not the only one who wants this.

Enhance the form with clientside submit#

Hey! What year is this? 1997? SSR forms? Seriously? Who submits their forms via plain HTTP today, right? Yes yes. I get it. We have come a long way and I think that most of us expect the page not to reload when we submit a form.

Hopefully by now you understand how SSR forms in SvelteKit work. Let's fast forward to today and talk about clientside form submits. SvelteKit starter demo has a nice example of the todo list that we can get some inspiration from. It uses Svelte action to enhance the form. We can use that example as base to add progressive enhancement to the login form.

src/routes/login/_utils.js
export function enhance(form, { done, error }) {
const onSubmit = async (e) => {
e.preventDefault();

const payload = new FormData(form);
try {
const res = await fetch(form.action, {
method: form.method,
headers: {
accept: 'application/json'
},
body: JSON.stringify(Object.fromEntries(payload))
});

if (res.ok) {
// get response payload
const resp = await res.json();
// pass response to done handler
done(resp, form);
} else {
const body = await res.text();
console.log(res.status); // 401
console.log(res.statusText); // Unauthorized
// return error as error object
error(new Error(body), form);
}
} catch (err) {
console.log(err);
error(err, form);
}
};

form.addEventListener('submit', onSubmit);

return {
destroy() {
form.removeEventListener('submit', onSubmit);
}
};
}

The action takes two functions as arguments - done and error. When the user submits the form we serialize the form as JSON and submit it to our API endpoint with the URL and method from the form object.

When the response comes back we check if it was successful or not and pass the result to the handler function, that were passed as action arguments, depending on the outcome. I am not sure what the logic for res.ok flag is, but my guess it's all HTTP 2XX statuses.

I don't know about you, but I find the enhance action pretty elegant. It can also be used with any form. In my opinion this is a perfect example of the power of Svelte actions.

The code example below illustrates how we can incorporate enhance function into our example.

src/routes/login/index.svelte
<script context="module">
export async function load({ url }) {
const query = url.searchParams || {};

if (query.has('error')) {
return {
props: {
error: query.get('error')
}
};
}

if (query.has('success')) {
return {
props: {
success: query.get('success') === 'true'
}
};
}

// you have to return an empty object in case none
// of the if statements match
return {};
}
</script>

<script>

import { enhance } from './_utils';

export let error = undefined;
export let success = false;

const onDone = (resp, form) => {
console.log(resp);
success = resp.success;
error = undefined;
form.reset();
};

const onError = (err, form) => {
// we return error as JSON from the endpoint
// so we can try to parse it here
const text = JSON.parse(err.message).message;
error = text;
success = false;

form.reset();
};

const reset = () => {
error = undefined;
success = false;
};
</script>

<h2>Login</h2>

<div>
{#if success}
<p class="success">You are successfuly logged in</p>
{/if}

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

<form
action="/login"
method="post"
use:enhance=
{{ done: onDone, error: onError }}>
...
</form>
</div>

Even though we can now do client side form submission it won't work because we need to handle JSON payloads in our API endpoint. Something that we need to fix next.

JSON support in SvelteKit API endpoint#

API endpoints in SvelteKit have no opinion on how you want to deal with different types of payloads. The information in request will help you make the decision, but the rest is up to you.

Because our API endpoint has to deal with both JSON and form-encoded payloads we need to have logic in place to see what type of request we are dealing with. The simplest way is to create a helper function to check the headers if it's a JSON request and then return back an object. SvelteKit will serialize the response to JSON for us if we return an object or array in the response body.

src/routes/login/index.js
const isJSON = (req) =>
req.headers.get('accept') == 'application/json';

const email = 'jane@example.com';
const password = 'qwerty';

/**
* @type {import('@sveltejs/kit').RequestHandler}
*/

export const post = async ({ request, url }) => {
// check if request is JSON
if (isJSON(request)) {
const data = await request.json();

if (data.email == email && data.password == password) {
return {
body: { success: true }
};
} else {
// You can also throw an error here which will result in 500 error
// throw new Error("Something went bonks")
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
}

const form = await request.formData();
const payload = Object.fromEntries(form);

// construct new redirect url
const endpoint = new URL(`http://${url.host}/login`);

if (payload.email == email && payload.password == password) {
// add query params
endpoint.searchParams.append('success', 'true');
} else {
endpoint.searchParams.append('error', 'Please check your credentials');
// You can also return HTTP error here instead
// return {
// status: 401,
// body: 'Wrong credentials'
// }
}

// redirect back to /login page
return {
headers: { Location: endpoint.toString() },
status: 302
};
};

We have some if branching in our function body to check if we are dealing with JSON and do an early return if this is the case. Otherwise we treat all other requests and plain HTTP requests.

I was hoping that SvelteKit would deserialize JSON body for us automatically, but it doesn't look that way. We have to do it ourselves. If something blows up SvelteKit will respond with HTTP 500 Internal Error so make sure to wrap your code in try/catch blocks and handle the errors yourself instead.

If you try to submit the form now it should work and if you disable JavaScript and submit the form it should also work. Mission accomplished!

In summary#

After you take a deeper look at SvelteKit's SSR forms they don't seem that hard, but the current documentation is thin and the examples are scarce.

SvelteKit is not your "traditional" web framework, but it's your "transitional" framework, right? Things might not work like you expect them to and there are also some features that I personally miss. For example, it would be nice to add isJSON property to the request object and deserialize the payload on the core level so we don't have to parse JSON manually. Maybe there is a good reason for it or maybe the functionality is just not in place yet. Time will tell!

If you are interested in learning more about how to work with forms in Svelte make sure to check out my book - Svelte Forms: The Missing Manual. It's packed with examples and I guarantee that they all work in SvelteKit.