Back to articles

Build a Fullstack CRUD App with SvelteKit and Supabase: A Beginner's Guide

AuthorMajd Muhtaseb11/09/202515 minutes

Introduction

This guide will walk you through building a simple to-do list application using SvelteKit for the frontend and backend API routes, and Supabase for the database and authentication. We'll cover the essential CRUD operations: creating, reading, updating, and deleting to-do items.

Prerequisites

  • Node.js (latest LTS version)
  • npm or pnpm
  • Supabase account (free tier is sufficient)

Setup

  1. Create a SvelteKit project:

    npm create svelte@latest my-todo-app
    cd my-todo-app
    npm install
    

    Choose the skeleton project. We will be adding Typescript for type safety.

  2. Initialize Supabase:

    Create a new project on Supabase. Once created, grab your Supabase URL and anon key from the API settings.

  3. Install Supabase JavaScript client:

    npm install @supabase/supabase-js
    npm install -D @sveltejs/adapter-auto
    

    Also, install Typescript packages that will aid with development

    npm install -D typescript svelte-check svelte-preprocess
    

Creating the Supabase Client

Create a file src/lib/supabaseClient.js:

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

Important: Store your Supabase URL and anon key in .env files and reference them using import.meta.env. Never commit these keys directly to your repository!

Create .env and .env.development files:

VITE_SUPABASE_URL="your_supabase_url"
VITE_SUPABASE_ANON_KEY="your_supabase_anon_key"

You might need to update svelte.config.js if you face issues with environmental variables. The following is a sample configuration

import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/kit/vite';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: vitePreprocess(),

	kit: {
		adapter: adapter(),
        vite: {
            define: {
                'process.env': process.env
            }
        }
	}
};

export default config;

Building the UI (src/routes/+page.svelte)

<script>
    import { supabase } from '$lib/supabaseClient';
    import { onMount } from 'svelte';

    let todos = [];
    let newTodo = '';

    onMount(async () => {
        await fetchTodos();
    });

    async function fetchTodos() {
        const { data, error } = await supabase
            .from('todos')
            .select('*')
            .order('created_at', { ascending: false });

        if (error) {
            console.error('Error fetching todos:', error);
            return;
        }

        todos = data;
    }

    async function addTodo() {
        if (!newTodo.trim()) return;

        const { data, error } = await supabase
            .from('todos')
            .insert([{ task: newTodo }])
            .single();

        if (error) {
            console.error('Error adding todo:', error);
            return;
        }

        todos = [data, ...todos];
        newTodo = '';
    }

    async function deleteTodo(id) {
        const { error } = await supabase
            .from('todos')
            .delete()
            .eq('id', id);

        if (error) {
            console.error('Error deleting todo:', error);
            return;
        }

        todos = todos.filter(todo => todo.id !== id);
    }

    async function toggleComplete(todo) {
        const { error } = await supabase
            .from('todos')
            .update({ completed: !todo.completed })
            .eq('id', todo.id)
            .single();

        if (error) {
            console.error('Error updating todo:', error);
            return;
        }

        todo.completed = !todo.completed;
        todos = [...todos]; // Trigger reactivity
    }
</script>

<input type="text" bind:value={newTodo} placeholder="Add new todo" />
<button on:click={addTodo}>Add</button>

<ul>
    {#each todos as todo (todo.id)}
        <li>
            <input type="checkbox" checked={todo.completed} on:change={() => toggleComplete(todo)} />
            <span class:completed={todo.completed}>{todo.task}</span>
            <button on:click={() => deleteTodo(todo.id)}>Delete</button>
        </li>
    {/each}
</ul>

<style>
    .completed {
        text-decoration: line-through;
        color: gray;
    }
    ul {
        list-style: none;
        padding: 0;
    }

    li {
        display: flex;
        align-items: center;
        margin-bottom: 5px;
    }

    li > * {
        margin-right: 10px;
    }
</style>

Creating the 'todos' Table in Supabase

In your Supabase dashboard, go to the SQL Editor and run the following SQL:

CREATE TABLE todos (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()),
    task TEXT NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
);

Enable Row Level Security (RLS) and create policies based on user ID. For this tutorial, we assume no auth and disable RLS. WARNING: this is highly insecure for production usage

Explanation

  • src/lib/supabaseClient.js: Initializes the Supabase client.
  • src/routes/+page.svelte:
    • Fetches todos on mount.
    • addTodo creates a new todo in Supabase.
    • deleteTodo deletes a todo from Supabase.
    • toggleComplete updates the completed status in Supabase.
    • The UI uses Svelte's reactivity to display the todos.

Running the Application

npm run dev -- --open

Your application will be running at http://localhost:5173/.

Next Steps

  • Implement user authentication with Supabase Auth.
  • Add more advanced features like editing todos, setting deadlines, etc.
  • Improve the UI with CSS frameworks like Tailwind CSS.