teta.so
SvelteKitSupabaseFull-StackTutorialDatabase

Build a Full-Stack App with SvelteKit and Supabase

AI Generated

Build a Full-Stack App with SvelteKit and Supabase

SvelteKit and Supabase together form one of the most productive full-stack combinations available today. SvelteKit gives you a lightning-fast frontend framework with server-side rendering, API routes, and file-based routing. Supabase provides a complete backend with a PostgreSQL database, authentication, real-time subscriptions, and storage — all without managing your own servers.

In this tutorial, you will build a complete task management application from scratch. By the end, you will have a working app with database CRUD operations, Row Level Security policies, and real-time updates that sync across browser tabs.

Prerequisites

Before starting, make sure you have:

  • Node.js 18 or later installed
  • A Supabase account (free tier works perfectly)
  • Basic familiarity with Svelte and TypeScript

Step 1: Create Your SvelteKit Project

Start by scaffolding a new SvelteKit project:

npx sv create task-manager
cd task-manager
npm install

Choose the following options when prompted:

  • Template: SvelteKit minimal
  • Type checking: TypeScript
  • Additional options: your preference

Step 2: Set Up Your Supabase Project

Head to supabase.com and create a new project. Once it is ready, navigate to Project Settings > API to find your project URL and anon key.

Install the Supabase client library:

npm install @supabase/supabase-js

Create a .env file in your project root:

PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

Step 3: Initialize the Supabase Client

Create a reusable Supabase client that works on both server and client. Create the file src/lib/supabase.ts:

import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Database } from './types/database';

export const supabase = createClient<Database>(
  PUBLIC_SUPABASE_URL,
  PUBLIC_SUPABASE_ANON_KEY
);

For server-side usage with the service role key, create src/lib/server/supabase.ts:

import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
import type { Database } from '$lib/types/database';

export const supabaseAdmin = createClient<Database>(
  PUBLIC_SUPABASE_URL,
  SUPABASE_SERVICE_ROLE_KEY
);

Step 4: Create Your Database Tables

Open the Supabase SQL Editor and run the following migration to create your tasks table:

CREATE TABLE tasks (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
  title TEXT NOT NULL,
  description TEXT,
  status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
  priority INTEGER DEFAULT 0 CHECK (priority BETWEEN 0 AND 3),
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Create an index for faster queries by user
CREATE INDEX idx_tasks_user_id ON tasks(user_id);

-- Create an updated_at trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tasks_updated_at
  BEFORE UPDATE ON tasks
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at();

Step 5: Add Row Level Security (RLS)

Row Level Security ensures users can only access their own data. This is critical for any multi-user application:

-- Enable RLS on the tasks table
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

-- Policy: Users can read their own tasks
CREATE POLICY "Users can read own tasks"
  ON tasks FOR SELECT
  USING (auth.uid() = user_id);

-- Policy: Users can insert their own tasks
CREATE POLICY "Users can insert own tasks"
  ON tasks FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Policy: Users can update their own tasks
CREATE POLICY "Users can update own tasks"
  ON tasks FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

-- Policy: Users can delete their own tasks
CREATE POLICY "Users can delete own tasks"
  ON tasks FOR DELETE
  USING (auth.uid() = user_id);

With these policies in place, even if someone modifies the client-side code, they cannot access another user's tasks. The database enforces security at the row level.

Step 6: Generate TypeScript Types

Supabase can generate TypeScript types from your database schema. Install the CLI and generate types:

npx supabase gen types typescript --project-id your-project-id > src/lib/types/database.ts

This gives you full type safety when querying your database. Every query result will be properly typed.

Step 7: Build the Server-Side Data Loading

Create the page server load function in src/routes/+page.server.ts:

import type { PageServerLoad, Actions } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
import { fail } from '@sveltejs/kit';

export const load: PageServerLoad = async ({ locals }) => {
  const userId = locals.user?.id;
  if (!userId) return { tasks: [] };

  const { data: tasks, error } = await supabaseAdmin
    .from('tasks')
    .select('*')
    .eq('user_id', userId)
    .order('created_at', { ascending: false });

  if (error) {
    console.error('Error loading tasks:', error);
    return { tasks: [] };
  }

  return { tasks };
};

export const actions: Actions = {
  create: async ({ request, locals }) => {
    const userId = locals.user?.id;
    if (!userId) return fail(401, { message: 'Unauthorized' });

    const formData = await request.formData();
    const title = formData.get('title') as string;
    const description = formData.get('description') as string;

    if (!title?.trim()) {
      return fail(400, { message: 'Title is required' });
    }

    const { error } = await supabaseAdmin
      .from('tasks')
      .insert({
        user_id: userId,
        title: title.trim(),
        description: description?.trim() || null
      });

    if (error) {
      return fail(500, { message: 'Failed to create task' });
    }

    return { success: true };
  },

  update: async ({ request, locals }) => {
    const userId = locals.user?.id;
    if (!userId) return fail(401, { message: 'Unauthorized' });

    const formData = await request.formData();
    const id = formData.get('id') as string;
    const status = formData.get('status') as string;

    const { error } = await supabaseAdmin
      .from('tasks')
      .update({ status })
      .eq('id', id)
      .eq('user_id', userId);

    if (error) {
      return fail(500, { message: 'Failed to update task' });
    }

    return { success: true };
  },

  delete: async ({ request, locals }) => {
    const userId = locals.user?.id;
    if (!userId) return fail(401, { message: 'Unauthorized' });

    const formData = await request.formData();
    const id = formData.get('id') as string;

    const { error } = await supabaseAdmin
      .from('tasks')
      .delete()
      .eq('id', id)
      .eq('user_id', userId);

    if (error) {
      return fail(500, { message: 'Failed to delete task' });
    }

    return { success: true };
  }
};

Step 8: Build the UI Component

Create the task management page in src/routes/+page.svelte:

<script lang="ts">
  import { enhance } from '$app/forms';

  let { data } = $props();

  let newTitle = $state('');
  let newDescription = $state('');

  const statusColors: Record<string, string> = {
    pending: 'bg-yellow-100 text-yellow-800',
    in_progress: 'bg-blue-100 text-blue-800',
    completed: 'bg-green-100 text-green-800'
  };
</script>

<div class="max-w-2xl mx-auto p-6">
  <h1 class="text-3xl font-bold mb-8">Task Manager</h1>

  <form method="POST" action="?/create" use:enhance class="mb-8 space-y-4">
    <input
      name="title"
      bind:value={newTitle}
      placeholder="Task title..."
      class="w-full p-3 border rounded-lg"
      required
    />
    <textarea
      name="description"
      bind:value={newDescription}
      placeholder="Description (optional)"
      class="w-full p-3 border rounded-lg"
      rows="2"
    ></textarea>
    <button
      type="submit"
      class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
    >
      Add Task
    </button>
  </form>

  <div class="space-y-3">
    {#each data.tasks as task (task.id)}
      <div class="p-4 border rounded-lg flex items-center justify-between">
        <div class="flex-1">
          <h3 class="font-semibold">{task.title}</h3>
          {#if task.description}
            <p class="text-gray-600 text-sm mt-1">{task.description}</p>
          {/if}
          <span class="inline-block mt-2 px-2 py-1 text-xs rounded-full {statusColors[task.status ?? 'pending']}">
            {task.status}
          </span>
        </div>

        <div class="flex gap-2 ml-4">
          <form method="POST" action="?/update" use:enhance>
            <input type="hidden" name="id" value={task.id} />
            <input type="hidden" name="status" value="completed" />
            <button class="text-green-600 hover:text-green-800">Done</button>
          </form>

          <form method="POST" action="?/delete" use:enhance>
            <input type="hidden" name="id" value={task.id} />
            <button class="text-red-600 hover:text-red-800">Delete</button>
          </form>
        </div>
      </div>
    {/each}
  </div>
</div>

Step 9: Enable Real-Time Subscriptions

One of Supabase's most powerful features is real-time subscriptions. Enable real-time on your table first by going to your Supabase dashboard, navigating to Database > Replication, and toggling replication on for the tasks table.

Then add real-time updates to your component:

<script lang="ts">
  import { supabase } from '$lib/supabase';
  import { invalidateAll } from '$app/navigation';
  import { onMount } from 'svelte';

  let { data } = $props();

  onMount(() => {
    const channel = supabase
      .channel('tasks-changes')
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'tasks'
        },
        () => {
          // Re-fetch data when changes occur
          invalidateAll();
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  });
</script>

With this setup, when a task is created, updated, or deleted in any browser tab, all other tabs automatically refresh their data. This works across devices too — open the app on your phone and desktop and watch changes sync instantly.

Step 10: Server-Side vs. Client-Side Queries

Understanding when to use server-side versus client-side queries is important:

Server-side queries (in +page.server.ts or +server.ts) are best for:

  • Initial page loads (better SEO, faster first paint)
  • Operations that need the service role key
  • Sensitive operations where you do not want to expose query logic

Client-side queries (using the browser Supabase client) are best for:

  • Real-time subscriptions
  • Interactive updates that need instant feedback
  • Operations after the page has loaded

A hybrid approach works best. Load initial data server-side, then subscribe to changes client-side:

// +page.server.ts - Initial load
export const load: PageServerLoad = async ({ locals }) => {
  const { data } = await supabaseAdmin
    .from('tasks')
    .select('*')
    .eq('user_id', locals.user.id);
  return { tasks: data ?? [] };
};
<!-- +page.svelte - Real-time updates -->
<script lang="ts">
  // Server data is the initial state
  let { data } = $props();

  // Real-time keeps it fresh
  // (subscription code from Step 9)
</script>

Handling Errors Gracefully

Always handle errors from Supabase queries. The client returns { data, error } tuples, never throws exceptions:

const { data, error } = await supabase
  .from('tasks')
  .select('*');

if (error) {
  console.error('Query failed:', error.message);
  // Show user-friendly message
  return;
}

// Safe to use data here

Summary

You have built a complete full-stack application with SvelteKit and Supabase that includes:

  • A PostgreSQL database with proper schema design
  • Full CRUD operations with form actions
  • Row Level Security for data protection
  • Real-time subscriptions for live updates
  • TypeScript types generated from your schema
  • Both server-side and client-side data fetching patterns

This architecture scales well. Supabase handles the backend complexity, while SvelteKit delivers a fast, accessible frontend. From here, you can add authentication (covered in our auth tutorial), file uploads with Supabase Storage, or full-text search with Supabase's PostgreSQL extensions.

The combination of SvelteKit's progressive enhancement (forms work without JavaScript) and Supabase's real-time capabilities gives you the best of both worlds: a resilient app that works everywhere, with a rich interactive experience when JavaScript is available.

Source: Teta Engineering

This content was generated with AI from public sources. It represents analysis and commentary, not original journalism.

Build your next app with AI

Try Teta — create sites and apps with AI agents.

Get started free