DEV Community

Valeria
Valeria

Posted on • Edited on • Originally published at valeriavg.dev

How to build a Web App in 11 minutes and fall in love with SvelteKit

It's been a long time since I got excited about a framework. I often advocate for reinventing the wheel, how come I'm writing an ode to a framework? Short answer: because SvelteKit is very good, even though it's still in beta. The long answer is ahead.

Svelte itself is like coming back to the future: you write your user interfaces with almost old-school HTML in a declarative manner with zero-to-none boilerplate. And then .svelte files are compiled to the plain old .js,.css and .html. Apps come out fast, lightweight and easy to maintain and extend.

But SvelteKit takes it even further. Heard of Create React App? Not even close! SvelteKit is a full-stack framework capable of producing not only single-page applications and static websites, but a versatile full-blown HTTP server with any pages, API and handlers NodeJS can have.

Alright, enough words let's build something already! And by something I mean an app where users can signup, log in and see account information.

In other words, we'll build a base for a generic web service.

Prerequisites & Architecture

For this tutorial you'll need NodeJS (v14.17.6 or higher).

It's also nice to have a code editor with Svelte extension (e.g. VSCode with svelte-vscode extension).

The app will store data in a simple in-memory database (literally an object) and write to a JSON file for persistence. Though you can replace it with a database of your choice.

For speed and simplicity, we'll use a minimalistic CSS framework called Milligram.

Creating the App

Open the terminal, paste or type npm init svelte@next my-app and choose the highlighted options:

npm init svelte@next my-app

# ✔ Which Svelte app template? › [Skeleton project]
# ✔ Use TypeScript? … No / [Yes]
# ✔ Add ESLint for code linting? … No / [Yes]
# ✔ Add Prettier for code formatting? … No / [Yes]
Enter fullscreen mode Exit fullscreen mode

Install dependencies from the app folder:

cd my-app && npm i
Enter fullscreen mode Exit fullscreen mode

You can now start the app in the development mode with:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000/ in your browser to see the ascetic home page.

Let's start with the layout: a file that will include global css styles and some constant parts of the page. Create file src/routes/__layout.svelte:

<svelte:head>
    <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"
    />
    <!-- CSS Reset -->
    <link
        rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css"
    />
    <!-- Milligram CSS -->
    <link
        rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css"
    />
</svelte:head>

<main class="container">
    <slot />
</main>
<footer class="container">
    {new Date().getFullYear()} &copy; MY APP
</footer>

<style>
    :global(body, html, #svelte) {
        width: 100vw;
        min-height: 100vh;
    }
    :global(#svelte) {
        display: flex;
        flex-direction: column;
    }
    main {
        flex: 1;
        margin-top: 3rem;
    }
    footer {
        margin-top: auto;
        font-size: 0.8em;
        opacity: 0.5;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

The page should be looking much better now because we replaced the default <slot></slot> layout with a bit more sophisticated one. SvelteKit will look for __layout.svelte file in the nearest or parent directory, so it's possible to use a different one for each nested folder.

As you can see Svelte is very close to HTML, though you probably have noticed the differences:

  • <svelte:head/> tag that contains contents that should be inserted into the <head/> tag of the final page
  • :global(selector) in style, pointing out that no scoped class should be created and instead, the selectors should be used as is
  • JavaScript code right in the middle of HTML contents

Creating Forms & Pages

To create a new page create a new file src/routes/signup.svelte:

<svelte:head>
    <title>Create an account</title>
</svelte:head>

<h1>Create an account</h1>

<form method="POST" action="/signup">
    <fieldset>
        <label for="email">Email</label>
        <input type="email" placeholder="user@email.net" name="email" required />
        <label for="password">Password</label>
        <input type="password" placeholder="Your password" name="password" required />
        <label for="password">Password, again</label>
        <input
            type="password"
            placeholder="Repeat the password, please"
            name="repeat-password"
            required
        />
        <input class="button-primary" type="submit" value="Signup" />
    </fieldset>
</form>
<p>Already have an account? <a href="/login">Login</a></p>

<style>
    form {
        max-width: 420px;
    }
</style>

Enter fullscreen mode Exit fullscreen mode

And src/routes/login.svelte:

<svelte:head>
    <title>Login</title>
</svelte:head>

<h1>Login</h1>
<form method="POST" action="/login">
    <fieldset>
        <label for="email">Email</label>
        <input type="email" placeholder="user@email.net" name="email" />
        <label for="password">Password</label>
        <input type="password" placeholder="Your password" name="password" />
        <input class="button-primary" type="submit" value="Login" />
    </fieldset>
</form>
<p>Don't have an account? <a href="/signup">Signup</a></p>

<style>
    form {
        max-width: 420px;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Navigate to http://localhost:3000/login or http://localhost:3000/signup to enjoy utterly useless forms that send data to themselves.

Creating API Route handlers

Update: adjusted Svelte Kit API to that of v1.0.0-next.259

To create a handler for POST /signup all we need to do is create a signup.ts (or .js, if you prefer) file in routes, exporting a post function. Simple, right?

But first, we need a couple of handy dependencies: uuid to generate unique user ID's and tokens and bcrypt to hash passwords:

npm i uuid bcrypt --save && npm i @types/uuid @types/bcrypt --save-dev
Enter fullscreen mode Exit fullscreen mode

You might need to restart the dev server after installing new dependencies.

Now let's create src/routes/signup.ts with:

import type { RequestHandler } from '@sveltejs/kit';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';

export const post: RequestHandler = async (event) => {
    const contentType = event.request.headers.get('content-type')
    const req = contentType === 'application/json' ? await event.request.json() : contentType?.includes('form') ? await event.request.formData() : null
    if (!req) return { status: 400, body: { error: 'Incorrect input' } };
    // Handle FormData & JSON
    const input = {
        email: ('get' in req ? req.get('email') : req.email)?.toLowerCase().trim(),
        password: 'get' in req ? req.get('password') : req.password,
        'repeat-password':
            'get' in req ? req.get('repeat-password') : req['repeat-password']
    };
    if (!input.password || !input.email)
        return { status: 400, body: { error: 'Email & password are required' } };

    if (input.password !== input['repeat-password'])
        return { status: 400, body: { error: 'Passwords do not match' } };

    const user = { id: uuidv4(), email: input.email, pwhash: await bcrypt.hash(input.password, 10) };

    return {
        status: 201,
        body: {
            user
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

If you submit the signup form now you'll see a page with JSON response like this:

{"user":{"id":"60d784c7-d369-4df7-b506-a274c962880e","email":"clark.kent@daily.planet","pwhash":"$2b$10$QiLRAFF5qqGxWuQjT3dIou/gZo2A0URImJ1YMSjOx2GYs0BxHt/TC"}}
Enter fullscreen mode Exit fullscreen mode

Writing handlers in SvelteKit is as simple as writing a function that returns an object with status, body and optional headers properties.

But we are not storing user information anywhere yet. To do so we need to add a global store and give our handler access to it.

First things first, let's create a poor-mans in-memory database in src/lib/db.ts:

import fs from 'fs/promises';

export type User = {
    id: string;
    email: string;
    pwhash: string;
};

export type UserToken = {
    id: string;
    email: string;
};

export interface DB {
    users: Map<string, User>;
    tokens: Map<string, UserToken>;
    __stop: () => void;
}

const DB_FILE = 'db.json';

export const initDB = async () => {
    let data: Record<string, Array<[string, any]>> = {};
    try {
        const str = await fs.readFile(DB_FILE);
        data = JSON.parse(str.toString());
    } catch (err) {
        console.error(`Failed to read ${DB_FILE}`, err);
    }
    const db: DB = {
        users: new Map<string, User>(data.users),
        tokens: new Map<string, UserToken>(data.tokens),
        __stop: () => { }
    };

    const interval = setInterval(async () => {
        try {
            await fs.writeFile(
                DB_FILE,
                JSON.stringify({ users: [...db.users.entries()], tokens: [...db.tokens.entries()] })
            );
        } catch (err) {
            console.error(`Failed to write ${DB_FILE}`, err);
        }
    }, 1_000);

    db.__stop = () => {
        clearInterval(interval);
    };

    return db;
};
Enter fullscreen mode Exit fullscreen mode

To give every route access to this "database" we can use hooks, which allow us to hook middleware(s) before or after any route handler. Expectedly a file src/hooks.ts will do the trick:

import { initDB } from '$lib/db';
import type { Handle } from '@sveltejs/kit';

// Create a promise, therefore start execution
const setup = initDB().catch((err) => {
    console.error(err);
    // Exit the app if setup has failed
    process.exit(-1);
});

export const handle: Handle = async ({ event, resolve }) => {
    // Ensure that the promise is resolved before the first request
    // It'll stay resolved for the time being
    const db = await setup;
    event.locals['db'] = db;
    const response = await resolve(event);
    return response;
};
Enter fullscreen mode Exit fullscreen mode

I intentionally made initDB function asynchronous to show how to do asynchronous startup via Promises. If it seems a bit like a hack, well, that's because it is, though I believe there will be a more straightforward way of doing it in the future.

Alright, now let's quickly add saving user to the database in the src/routes/signup.ts:

import type { RequestHandler } from '@sveltejs/kit';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';
import type { DB } from '$lib/db';

export const post: RequestHandler<
    { db: DB },
    Partial<{ email: string; password: string; ['repeat-password']: string }>
> = async (req) => {
    if (typeof req.body == 'string' || Array.isArray(req.body))
        return { status: 400, body: { error: 'Incorrect input' } };

    // Handle FormData & JSON
    const input = {
        email: ('get' in req.body ? req.body.get('email') : req.body.email)?.toLowerCase().trim(),
        password: 'get' in req.body ? req.body.get('password') : req.body.password,
        'repeat-password':
            'get' in req.body ? req.body.get('repeat-password') : req.body['repeat-password']
    };

    if (input.password !== input['repeat-password'])
        return { status: 400, body: { error: 'Passwords do not match' } };

    const db = req.locals.db;
    const user = { id: uuidv4(), email: input.email, pwhash: await bcrypt.hash(input.password, 10) };
        // Store in DB
    db.users.set(user.email, user);
    return {
        status: 201,
        body: {
            user
        }
    };
};

Enter fullscreen mode Exit fullscreen mode

If you submit the form again and check db.json in a second - you'll see your data there.

Now let's write a login function in src/routes/login.ts

import type { RequestHandler } from '@sveltejs/kit';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';
import type { DB } from '$lib/db';
export const post: RequestHandler = async (event) => {
    const contentType = event.request.headers.get('content-type')
    const req = contentType === 'application/json' ? await event.request.json() : contentType?.includes('form') ? await event.request.formData() : null
    if (!req) return { status: 400, body: { error: 'Incorrect input' } };

    // Handle FormData & JSON
    const input = {
        email: ('get' in req ? req.get('email') : req.email)?.toLowerCase().trim(),
        password: 'get' in req ? req.get('password') : req.password
    };

    const db = event.locals['db'] as DB;
    const user = db.users.get(input.email);

    if (!user) return { status: 400, body: { error: 'Incorrect email or password' } };

    const isPasswordValid = await bcrypt.compare(input.password, user.pwhash);

    if (!isPasswordValid) return { status: 400, body: { error: 'Incorrect email or password' } };

    const token = { id: uuidv4(), email: user.email };
    db.tokens.set(token.id, token);

    return {
        status: 200,
        body: {
            user
        },
        headers: {
            'set-cookie': `token=${token.id}`
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

In this function, we check if a user with this email exists, verify provided password against the saved hash and either return an error or create a new token and set it as a session cookie.

In a production app you should set an expiration date to both cookies and stored tokens.

Go ahead and try logging in with correct and then wrong credentials. It works and it works without any client JavaScript, which is great for compatibility, but is a bit meh.

Reusable Svelte components

Both of our login and signup pages are pretty much the same and the functionality is quite similar. Therefore, let's write a component to use in both of them. Create src/routes/_form.svelte:

<script lang="ts">
    import type { User } from '$lib/db';
    import { afterUpdate } from 'svelte';
    export let action = '/';
    export let method = 'POST';

    type Result = { error?: string; user?: User };

    export let onUpdate: (state: { result: Result; isSubmitting: boolean }) => void = () => {};
    let result: Result;
    let isSubmitting = false;

    const onSubmit = async (e) => {
        e.preventDefault();
        if (isSubmitting) return;
        isSubmitting = true;
        const form: HTMLFormElement = e.target.form;
        const formData = new FormData(form);
        const data: Record<string, string> = {};
        formData.forEach((value, key) => {
            data[key] = value.toString();
        });

        result = await fetch(form.action, {
            method: form.method,
            headers: {
                'content-type': 'application/json'
            },
            body: JSON.stringify(data)
        })
            .then((r) => r.json())
            .catch((err) => {
                return { error: err.toString() };
            });
        isSubmitting = false;
    };
    $: error = result?.error;

    afterUpdate(() => onUpdate({ result, isSubmitting }));
</script>

<form {method} {action} on:click={onSubmit}>
    <slot />
    {#if error}
        <p class="error">{error}</p>
    {/if}
</form>

<style>
    form {
        max-width: 420px;
    }
    .error {
        color: red;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Simply exporting values from a Svelte component makes them properties, similar to a JSX / React Component. And a <slot/> tag determines the spot for the inner HTML or other Svelte components.

And now let's import and use this component in src/routes/login.svelte:

<script lang="ts">
    import { goto } from '$app/navigation';
    import { session } from '$app/stores';
    import Form from './_form.svelte';
    let isSubmitting: boolean;
    session.subscribe(() => {});
    const onUpdate = (form) => {
        isSubmitting = form.isSubmitting;
        if (form.result?.user) {
            session.set({ user: { email: form.result.user.email } });
            alert('You are logged in!');
            goto('/');
        }
    };
</script>

<svelte:head>
    <title>Login</title>
</svelte:head>

<h1>Login</h1>
<Form action="/login" {onUpdate}>
    <fieldset>
        <label for="email">Email</label>
        <input type="email" placeholder="user@email.net" name="email" />
        <label for="password">Password</label>
        <input type="password" placeholder="Your password" name="password" />
        <input class="button-primary" type="submit" value="Login" disabled={isSubmitting} />
    </fieldset>
</Form>

<p>Don't have an account? <a href="/signup">Signup</a></p>
Enter fullscreen mode Exit fullscreen mode

Here we are also setting session state so that other pages will have access to user information.

Let's add the <Form/> to src/routes/signup.svelte as well:

<script lang="ts">
    import { goto } from '$app/navigation';
    import Form from './_form.svelte';
    let isSubmitting: boolean;
    const onUpdate = (form) => {
        isSubmitting = form.isSubmitting;
        if (form.result?.user) {
            alert('You are signed up!');
            goto('/login');
        }
    };
</script>

<svelte:head>
    <title>Create an account</title>
</svelte:head>

<h1>Create an account</h1>
<Form action="/signup" {onUpdate}>
    <fieldset>
        <label for="email">Email</label>
        <input type="email" placeholder="user@email.net" name="email" required />
        <label for="password">Password</label>
        <input type="password" placeholder="Your password" name="password" required />
        <label for="password">Password, again</label>
        <input
            type="password"
            placeholder="Repeat the password, please"
            name="repeat-password"
            required
        />
        <input class="button-primary" type="submit" value="Signup" disabled={isSubmitting} />
    </fieldset>
</Form>
<p>Already have an account? <a href="/login">Login</a></p>
Enter fullscreen mode Exit fullscreen mode

Now you should be able to create an account and log in without annoying raw JSON (but with annoying alerts instead :-) )

User-only content

The whole point of user authentication is to show something that only a certain user should see. That's why we are going to make some changes to the src/routes/index.svelte page:

<script lang="ts">
    import { session } from '$app/stores';
    import type { User } from '$lib/db';
    let user: User | undefined;
    session.subscribe((current) => {
        user = current.user;
    });
    $: username = user ? user.email : 'Guest';
</script>

<svelte:head>
    <title>Welcome, {username}!</title>
</svelte:head>

<h1>Welcome, {username}!</h1>
{#if user}
    <p>You are logged in!</p>
{:else}
    <p>Would you like to <a href="/login">Login</a>?</p>
{/if}
Enter fullscreen mode Exit fullscreen mode

Now, when you log in, you should see your email on the home page, but if you reload the page you will only see the Guest state, as we don't have access to the server session yet. To pass server session state to the client we need to modify src/hooks.ts:

import { initDB } from '$lib/db';
import type { GetSession, Handle } from '@sveltejs/kit';
import { parse } from 'querystring';

// Create a promise, therefore start execution
const setup = initDB().catch((err) => {
    console.error(err);
    // Exit the app if setup has failed
    process.exit(-1);
});

export const handle: Handle = async ({ event, resolve }) => {
    // Ensure that the promise is resolved before the first request
    // It'll stay resolved for the time being
    const db = await setup;
    event.locals['db'] = db;
    const cookies = event.request.headers.get('cookie')
        ?.split(';')
        .map((v) => parse(v.trim()))
        .reduceRight((a, c) => {
            return Object.assign(a, c);
        });
    if (cookies?.token && typeof cookies.token === 'string') {
        const existingToken = db.tokens.get(cookies.token);
        if (existingToken) {
            event.locals['user'] = db.users.get(existingToken.email);
        }
    }
    const response = await resolve(event);
    return response;
};

export const getSession: GetSession = (event) => {
    return event.locals['user']
        ? {
            user: {
                // only include properties needed client-side —
                // exclude anything else attached to the user
                // like access tokens etc
                email: event.locals['user'].email
            }
        }
        : {};
};
Enter fullscreen mode Exit fullscreen mode

We added yet another hook called getSession that makes server values accessible on the client-side and during pre-render.

Another improvement has been made to the handle hook, which now determines which user is currently logged in based on the token cookie.

Load the page one more time to see something like:

# Welcome, clark.kent@daily.planet!

You are logged in!
Enter fullscreen mode Exit fullscreen mode

What's next?

While SvelteKit is still in beta it might not be suitable for mission-critical applications yet, but it seems to be getting there fast.

Nonetheless, if you would like to deploy your app for the world to see, you'll need an adapter. For this app and overall a generic Node app you can use @sveltejs/adapter-node@next, but there's a lot of other options, including static site generation or oriented to a particular type of deployment. And you can always write your own, it's really simple.

I love how close to the actual Web ( as in HTML, CSS, JS) Svelte is and SvelteKit feels the same way with its predictable HTTP abstractions.

What do you think, reader? Excited to give it a try yet?

Top comments (16)

Collapse
 
fyodorio profile image
Fyodor

Thanks for the great post 👍 The SvelteKit approach looks really neat.

A Svelte-noob question about the folder structure: why some files are in src/..., some in src/lib/..., some insrc/routes/...` (incl. the form component) and some in the root? Is it intentional and reflects some common practices in Svelte, or it's just for the showcase purposes?

Collapse
 
valeriavg profile image
Valeria

Thank you) Svelte kit uses file names as route paths, therefore all the routes need to be in the src/routes folder. E.g. if you would like to render a page /blog/some-nice-title you would create a /src/routes/blog/some-nice-title.svelte. Same goes for the .ts ( or js) files with method handlers exported.
And the lib folder is conveniently aliased to avoid imports from ../../../../../src/lib/file.ts, so that you could simply import $lib/file.ts

Hope that helps:-)

Collapse
 
fyodorio profile image
Fyodor

Cool, very neat 🔥

Collapse
 
nstuyvesant profile image
Nate Stuyvesant

Nice job with the article. Trying to figure out how to make getSession() in hooks.ts useful. It only appears to fire once when I launch the dev server loading the home page. github.com/nstuyvesant/sveltekit-a....

Collapse
 
valeriavg profile image
Valeria

TL; DR; This is the correct behaviour. Write to client session storage whenever it changes.

As per documentation, getSession runs only when the page is rendered on the server. If this initial state is changing on the front-end you need to make sure the changes are reflected in the store.

To quote the docs once again:

session is a writable store whose initial value is whatever was returned from getSession. It can be written to, but this will not cause changes to persist on the server — this is something you must implement yourself.

Collapse
 
nstuyvesant profile image
Nate Stuyvesant

While my SPA has several pages in /src/routes, only /src/routes/index.svelte appears to trigger getSession() in hooks (guessing it's because client side routing has taken over). If it's a user's first visit to the site (no session id in a cookie to lookup in handle() so request.locals.user doesn't get populated), getSession() doesn't seem to have much value.

What I've been doing in my /login endpoint (github.com/nstuyvesant/sveltekit-a...) is putting the user in the body of the endpoint response then doing a session.set({ user: fromEndpoint.user }) on the client in the login() method that called the endpoint.

As all the examples of authentication using SvelteKit made use of getSession(), I thought I was doing something wrong.

Collapse
 
blancout profile image
Edimilson Blanes Coutinho • Edited

I´m getting a error at line:
if (typeof req.body == 'string' || Array.isArray(req.body))

Property 'body' does not exist on type 'RequestEvent<{ db: DB; }, Partial<{ email: string; password: string; "repeat-password": string; }>>'.

Please, how to fix it ?

Collapse
 
valeriavg profile image
Valeria

In the article I've used type RequestHandler, your version seems to be using RequestEvent. Is it a typo or the original type is no longer available for import?

Collapse
 
blancout profile image
Edimilson Blanes Coutinho

I copied the text as you wrote it using RequestHandler but something is changing in the compilation for RequestEvent. I believe it's the sveltekit version, I'm using the most current one.

Thread Thread
 
valeriavg profile image
Valeria

I believe you're right and it seems that the way routes are defined has been changed since I wrote the article. Please take a look at svelte kit docs while I'm updating examples to the current version.

Thread Thread
 
valeriavg profile image
Valeria

@blancout I've updated the article to reflect API of latest version, thank you for letting me know!

Thread Thread
 
blancout profile image
Edimilson Blanes Coutinho

You´re wonderful !

Collapse
 
fsrtechnologies profile image
Marc • Edited

One typo that tripped me up and might cause problems for others:

Let's add the "<Form/>" to src/routes/signup.ts as well:

That should be "src/routes/signup.svelte", not signup.ts.
Otherwise, excellent post! Thank you!

Collapse
 
valeriavg profile image
Valeria

Oops! Fixed it, thank you!
Glad you liked it!

Collapse
 
zefur profile image
The Silent Partner

Hi there, I am trying to follow along with this for practice and coming into the bug where I cannot get the data from req.locals.db when trying to save to the DB was there a work around for this? I am new to this so I have been un able to find a proper work around myself

Collapse
 
zefur profile image
The Silent Partner

Ignore me I think I sorted it =]