Svelte journey | SvelteKit: Server-side, SvelteKit utility stores, Error handling, Hooks

chillyhill

Denys Sych

Posted on January 11, 2024

Svelte journey | SvelteKit: Server-side, SvelteKit utility stores, Error handling, Hooks

Welcome,

In the previous part, we started to explore SvelteKit, and today we’ll continue on that. The previous chapter touched on routing, data loading, and the concept of shared modules in SvelteKit.

This article will focus on server-side interaction, specifically API Routes, which are included in SvelteKit. We will also cover form submitting actions with progressive enhancement, specific stores built into the kit, and error handling.

Lastly, we will discuss hooks that help customize default SvelteKit mechanisms for data loading and error handling.

Forms — server-side interaction without client-side JS execution

A form is a standard tool for sending data back to the server:

// src/routes/+page.svelte
<form method="POST">
    <label>
        add a todo:
        <input
            name="description"
            autocomplete="off"
        />
    </label>
</form>
Enter fullscreen mode Exit fullscreen mode

To handle this on the server-side, actions should be used inside +page.server.js. The request is a standard Request object, and await request.formData() returns a FormData instance:

// src/routes/+page.server.js
export function load() {
    // ...
}

export const actions = {
  // Unnamed form = default action
    default: async ({ cookies, request }) => {
        const data = await request.formData();
        db.createTodo(cookies.get('userid'), data.get('description'));
    }
};
Enter fullscreen mode Exit fullscreen mode
  • No JS fetching here → the application operates even with disabled JS.

Named form actions

  • Default actions cannot coexist with named actions;
  • The action attribute can be any URL. If the action was defined on another page, you might have something like /todos?/create;
  • When the action is on this page, we can omit the pathname altogether and use just ?;
  • After an action runs, the page will be re-rendered, and the load action will run after the action completes;
  • Actions are always about POST requests.
// src/routes/+page.svelte
<form method="POST" action="?/create">
    <label>
        add a todo:
        <input
            name="description"
            autocomplete="off"
        />
    </label>
</form>
Enter fullscreen mode Exit fullscreen mode
// src/routes/+page.server.js
export const actions = {
  // action="?/create"
    create: async ({ cookies, request }) => { // ...    }
};
Enter fullscreen mode Exit fullscreen mode

Markup-based validation

The most basic one, like required in <input name="title" required />.

Server-side validation

The common flow is as follows:

  • Throw new Error() from a service (e.g., src/lib/server/database.js);
  • Catch this error in +page.server.js (e.g., src/routes/+page.server.js);
  • Show the error in +page.js by accessing the form's metadata through export let form.
// src/lib/server/database.js
export function createTodo(userid, description) {
    if (description === '') {
        throw new Error('todo must have a description'); // 1. Throw an error
    }
}

// src/routes/+page.server.js
import { fail } from '@sveltejs/kit';
import * as db from '$lib/server/database.js';

export const actions = {
    create: async ({ cookies, request }) => {
        const data = await request.formData();

        try {
            db.createTodo(cookies.get('userid'), data.get('description'));
        } catch (error) {
            return fail(422, { // 2. Catch and return expected failure
                description: data.get('description'),
                error: error.message
            });
        }
    }
};

// src/routes/+page.svelte
<script>
    export let data;
    export let form;
</script>

<div class="centered">
    <h1>todos</h1>

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

    <form method="POST" action="?/create">
        <label>
            add a todo:
            <input
                name="description"
                value={form?.description ?? ''}
                autocomplete="off"
                required
            />
        </label>
    </form>
</div>

Enter fullscreen mode Exit fullscreen mode

Instead of using fail, you can also perform a redirect or just return some data — it will be accessible via form.

Progressive enhancement use:enhance — SPA-like API interaction

When JS is enabled in a client's environment, Svelte progressively enhances the functionality without full-page reloads. The same happens with <a/> anchor navigations.

  • Updates the form prop;
  • Invalidates all data on a successful response, causing load functions to re-run;
  • Navigates to the new page on a redirect response;
  • Renders the nearest error page if an error occurs.
<script>
    import { enhance } from '$app/forms';
</script>

<form method="POST" action="?/create" use:enhance>
    <!-- Form content -->
</form>
Enter fullscreen mode Exit fullscreen mode

Customised enhancing for optimistic updates, pending states, etc.

For example, there is usually some delay in server-to-client communication, which requires loading states.

Pass a callback to use:enhance to handle such cases:

<script>
    import { fly, slide } from 'svelte/transition';
    import { enhance } from '$app/forms';

    export let data;
    export let form;

    let creating = false;
    let deleting = [];
</script>

<form
    method="POST"
    action="?/create"
    use:enhance={() => { // <--- custom handling happens here
        creating = true;
        return async ({ update }) => {
            await update();
            creating = false;
        };
    }}
>
  {#if creating}
      <span class="saving">saving...</span>
  {/if}
    <label>
        add a todo:
        <input
            disabled={creating}
            name="description"
            value={form?.description ?? ''}
        />
    </label>
    <!-- Additional form elements -->
</form>
Enter fullscreen mode Exit fullscreen mode

More details on use:enhance can be found here. An extract from the documentation:

<form
    method="POST"
    use:enhance={({ formElement, formData, action, cancel, submitter }) => {
        // `formElement` is this `<form>` element
        // `formData` is its `FormData` object that's about to be submitted
        // `action` is the URL to which the form is posted
        // Calling `cancel()` will prevent the submission
        // `submitter` is the `HTMLElement` that caused the form to be submitted

        return async ({ result, update }) => {
            // `result` is an `ActionResult` object
            // `update` is a function that triggers the default logic that would be triggered if this callback wasn't set
        };
    }}
>
    <!-- Form content -->
</form>
Enter fullscreen mode Exit fullscreen mode

API Routes

SvelteKit allows you to define API routes that handle server-side functionality. These routes can be used to fetch data from databases, interact with external APIs, file system etc. or perform any other server-side operations. This makes it seamless to integrate your frontend with backend functionality.

  • Supports all default HTTP methods: GETPUTPOSTPATCH and DELETE. Function name in +server.js should be the same as the method name;
  • Request handlers must return a Response object;
  • From the client (+page.js), you need to make requests as from usual JS code;
// src/routes/todo/+server.js
import { json } from '@sveltejs/kit';
import * as database from '$lib/server/database.js';

export async function GET() {
    return json(await database.getTodos());
}

export async function POST({ request, cookies }) {
    const { description } = await request.json();
    const userid = cookies.get('userid');
    const { id } = await database.createTodo({ userid, description });

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

// src/routes/todo/+page.svelte
<script>
    export let data;
</script>
<!-- Additional page content -->
<input
    type="text"
    autocomplete="off"
    on:keydown={async (e) => {
        if (e.key !== 'Enter') return;
        const input = e.currentTarget;
        const description = e.currentTarget.value;

        const response = await fetch('/todo', {
            method: 'POST',
            body: JSON.stringify({ description }),
            headers: {
                'Content-Type': 'application/json'
            }
        });

        const { id } = await response.json();

        data.todos = [...data.todos, {
            id,
            description
        }];

        input.value = '';
    }}
/>

Enter fullscreen mode Exit fullscreen mode

Stores

We’ve met stores before, now we are going to see predefined, readonly stores in SvelteKit. There are three of them: pagenavigating, and updated. They are available via $app/stores module.

Page

Provides information about the current page:

  • url — the URL of the current page;
  • params — the current page's parameters;
  • route — an object with an id property representing the current route;
  • status — the HTTP status code of the current page;
  • error — the error object of the current page, if any;
  • data — the data for the current page, combining the return values of all load functions;
  • form — the data returned from a form action.
// src/routes/+layout.svelte
<script>
    import { page } from '$app/stores';
</script>

<nav>
    <a href="/" class:active={$page.url.pathname === '/'}>
        home
    </a>
    <a href="/about" class:active={$page.url.pathname === '/about'}>
        about
    </a>
</nav>

<slot />
Enter fullscreen mode Exit fullscreen mode

Navigating

The navigating store represents the current navigation:

  • from and to — objects with paramsroute and url properties;
  • type — the type of navigation:
    • form: The user submitted a <form>;
    • leave: The app is being left either because the tab is being closed or a navigation to a different document is occurring;
    • link: Navigation was triggered by a link click;
    • goto: Navigation was triggered by a goto(...) call or a redirect;
    • popstate: Navigation was triggered by back/forward navigation;
  • willUnload — Whether or not the navigation will result in the page being unloaded (i.e. not a client-side navigation);
  • delta? — in case of a history back/forward navigation, the number of steps to go back/forward;
  • complete — promise that resolves once the navigation is complete, and rejects if the navigation fails or is aborted. In the case of a willUnload navigation, the promise will never resolve.
// src/routes/+layout.svelte
<script>
    import { page, navigating } from '$app/stores';
</script>
<!-- Additional layout content -->
{#if $navigating}
    navigating to {$navigating.to.url.pathname}
{/if}
Enter fullscreen mode Exit fullscreen mode

Updated

The updated store contains true or false depending on whether a new version of the app has been deployed since the page was first opened. For this to work, your svelte.config.js must specify kit.version.pollInterval.

Errors and Redirects

We can either throw error or redirect (import { redirect, error } from '@sveltejs/kit' , e.g. throw redirect(307, '/maintenance'). redirect can be called:

  • inside load functions;
  • form actions;
  • API routes;
  • handle hook (described in the next section).

The most common status codes:

  • 303 — form actions, after a successful submission;
  • 307 — temporary redirects;
  • 308 — permanent redirects.

Hooks

A way to intercept and override the framework's default behaviour. Can be also named as interceptors at some point.

  • there are two types of hooks: server (should be in src/hooks.server.js) and client (src/hooks.server.js) ones. They also can be shared (environment-specific) and universal (run both on client and server);
  • mentioned file paths are by default, it can be customized via config.kit.files.hooks.

handle hook

  • server hook only;
  • runs every time server receives a request and modifies the response (runtime and pre-rendering);
  • requests to static assets and already pre-rendered pages — not handled by SvelteKit;
  • multiple handle hooks can be added via sequence utility function;
export async function handle({ event, resolve }) {
    return await resolve(event, optionalParams);
}
Enter fullscreen mode Exit fullscreen mode
  • resolve — SvelteKit matches the incoming request URL to a route of your app, imports the relevant code (+page.server.js and +page.svelte files and so on), loads the data needed by the route, and generates the response;

event object in server hooks

event object passed into handle is the same object — an instance of a [RequestEvent](<https://kit.svelte.dev/docs/types#public-types-requestevent>) — that is passed into API routes in +server.js files, form actions in +page.server.js files, and load functions in +page.server.js and +layout.server.js.

It has inside:

  • cookies — the cookies API;
  • fetch — the fetch API with extra perks that are described in handleFetch hook (and can be modified by this hook);
  • getClientAddress() — a function to get the client's IP address;
  • isDataRequesttrue if the browser is requesting data for a page during client-side navigation, false if a page/route is being requested directly;
  • locals — a place to put arbitrary data. Useful pattern: add some data here so it can be accessed in subsequent load functions;
  • params — the route parameters;
  • request — the Request object;
  • route — an object with an id property representing the route that was matched;
  • setHeaders(...);
  • url — a URL object representing the current request.

event.fetch extra functionality

  • make credentialed requests on the server, as it inherits the cookie and authorization headers from the incoming request;
  • relative requests on the server (without providing origin);
  • internal requests (e.g. for +server.js routes) run internally, without actual HTTP call.

handleFetch hook

The hook that allows modifying event’s fetch behaviour.

  • server hook only;
export async function handleFetch({ event, request, fetch }) {
    const url = new URL(request.url);
    return url.pathname === '/foo' ? fetch('/bar') : fetch(request);
}
Enter fullscreen mode Exit fullscreen mode

handleError hook

Interceptor for unexpected errors.:

  • it is shared hook — can be added to src/hooks.server.js and src/hooks.client.js;
  • can be used to log an error somewhere or some other side effect;
  • the default message can be either ‘Internal error’ or ‘Not found’. This hook allows customizing the message and the error object itself;
  • it never throws an error.
export function handleError({ event, error }) {
    return {
        message: 'Unknown error happened!',
        code: generateCode(error)
    };
}
Enter fullscreen mode Exit fullscreen mode

reroute hook

  • universal hook, can be added to src/hooks.js;
  • allows you to change how URLs are translated into routes;
  • src/routes/[[lang]]/about/+page.svelte page → /en/about or /de/ueber-uns/fr/a-propos. Achieavable with reroute:
const translated = {
    '/en/about': '/en/about',
    '/de/ueber-uns': '/de/about',
    '/fr/a-propos': '/fr/about',
};

export function reroute({ url }) {
    if (url.pathname in translated) {
        return translated[url.pathname];
    }
}
Enter fullscreen mode Exit fullscreen mode

That's all for today, and it's a massive chunk – well done! Just a couple more steps, and we're ready to dive into our own projects with confidence, armed with new knowledge.

Take care, go Svelte!

Resources

💖 💪 🙅 🚩
chillyhill
Denys Sych

Posted on January 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related