Build a Full-Stack App with SvelteKit and Supabase
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.