teta.so

SvelteKit + Supabase: The Full-Stack Tutorial

Learn how to build a full-stack app with SvelteKit and Supabase, covering database CRUD, authentication, real-time, and Row Level Security.

Building a full-stack web application used to require stitching together a frontend framework, a backend API, a database, an authentication system, and a hosting provider. SvelteKit and Supabase together eliminate most of that complexity. SvelteKit handles the frontend, routing, server-side rendering, and API endpoints. Supabase provides a PostgreSQL database, authentication, real-time subscriptions, file storage, and edge functions — all accessible through a simple client library. This tutorial walks through building a complete application with both, covering everything from initial setup to deployment. If you want a pre-configured environment with SvelteKit and Supabase already connected, Teta sets this up automatically when you create a new project.

Why Supabase and SvelteKit Work Well Together

The pairing of SvelteKit and Supabase is not just convenient — it is architecturally complementary.

SvelteKit runs code on both the server and the client. Its +page.server.ts files execute exclusively on the server, which means you can safely use your Supabase service role key for admin operations without exposing it to the browser. Its +page.ts (universal) load functions and client-side code use the public anon key with Row Level Security (RLS) to ensure users can only access their own data.

Supabase provides a REST API (PostgREST) and a real-time WebSocket API on top of PostgreSQL. The @supabase/supabase-js client library wraps these into a clean, chainable API that feels natural in both server and client contexts.

The @supabase/ssr package bridges the gap for server-rendered frameworks. It manages auth tokens as HTTP cookies rather than localStorage, which means the server always has access to the user's session — critical for SSR, protected routes, and server-side data fetching.

Setting Up Supabase with SvelteKit

Create a Supabase Project

Go to supabase.com and create a new project. Note your project URL and anon key from the project settings.

Install Dependencies

npm install @supabase/supabase-js @supabase/ssr

Environment Variables

Add your Supabase credentials to .env:

PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

The PUBLIC_ prefix makes variables available in client-side code. The service role key stays server-only.

Server Hook

Set up the Supabase client in hooks.server.ts so it is available on every server-side request:

// src/hooks.server.ts
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.supabase = createServerClient(
    PUBLIC_SUPABASE_URL,
    PUBLIC_SUPABASE_ANON_KEY,
    {
      cookies: {
        getAll: () => event.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => {
            event.cookies.set(name, value, { ...options, path: '/' });
          });
        }
      }
    }
  );

  return resolve(event);
};

Database Operations (CRUD)

Let's build a simple task manager to demonstrate CRUD operations. First, create the table in Supabase:

CREATE TABLE tasks (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id) NOT NULL,
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable RLS
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

-- Users can only see their own tasks
CREATE POLICY "Users can view own tasks"
  ON tasks FOR SELECT
  USING (auth.uid() = user_id);

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

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

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

Reading Data

// src/routes/app/tasks/+page.server.ts
export async function load({ locals: { supabase } }) {
  const { data: tasks, error } = await supabase
    .from('tasks')
    .select('*')
    .order('created_at', { ascending: false });

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

  return { tasks };
}

Because RLS is enabled, this query automatically returns only the current user's tasks — no WHERE clause needed.

Creating Data

// src/routes/app/tasks/+page.server.ts
import { fail } from '@sveltejs/kit';

export const actions = {
  create: async ({ request, locals: { supabase } }) => {
    const formData = await request.formData();
    const title = formData.get('title') as string;

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

    const { data: { session } } = await supabase.auth.getSession();

    const { error } = await supabase
      .from('tasks')
      .insert({ title: title.trim(), user_id: session?.user.id });

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

    return { success: true };
  }
};

Updating Data

// Add to the existing actions object
  toggle: async ({ request, locals: { supabase } }) => {
    const formData = await request.formData();
    const id = formData.get('id') as string;
    const completed = formData.get('completed') === 'true';

    const { error } = await supabase
      .from('tasks')
      .update({ completed: !completed })
      .eq('id', id);

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

Deleting Data

  delete: async ({ request, locals: { supabase } }) => {
    const formData = await request.formData();
    const id = formData.get('id') as string;

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

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

The Page Component

<!-- src/routes/app/tasks/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';

  let { data } = $props();
</script>

<h1>My Tasks</h1>

<form method="POST" action="?/create" use:enhance>
  <input type="text" name="title" placeholder="Add a task..." required />
  <button type="submit">Add</button>
</form>

<ul>
  {#each data.tasks as task}
    <li class:completed={task.completed}>
      <form method="POST" action="?/toggle" use:enhance>
        <input type="hidden" name="id" value={task.id} />
        <input type="hidden" name="completed" value={task.completed} />
        <button type="submit">{task.completed ? 'Undo' : 'Done'}</button>
      </form>
      <span>{task.title}</span>
      <form method="POST" action="?/delete" use:enhance>
        <input type="hidden" name="id" value={task.id} />
        <button type="submit">Delete</button>
      </form>
    </li>
  {/each}
</ul>

Authentication with Supabase

Authentication setup with SvelteKit and Supabase involves creating login/signup form actions and a callback route for OAuth providers. The hooks.server.ts file we created earlier already handles session management.

// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';

export const actions = {
  login: async ({ request, locals: { supabase } }) => {
    const formData = await request.formData();
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    const { error } = await supabase.auth.signInWithPassword({ email, password });

    if (error) return fail(400, { error: error.message, email });

    redirect(303, '/app/tasks');
  },

  signup: async ({ request, locals: { supabase } }) => {
    const formData = await request.formData();
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    const { error } = await supabase.auth.signUp({ email, password });

    if (error) return fail(400, { error: error.message, email });

    return { success: true, message: 'Check your email for a confirmation link.' };
  }
};

For a deeper dive into authentication patterns, including social login and protected routes, see our SvelteKit Authentication guide.

Real-Time Subscriptions

One of Supabase's standout features is real-time subscriptions. You can listen for database changes and update the UI instantly without polling.

<!-- src/routes/app/tasks/+page.svelte (enhanced with real-time) -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { onMount } from 'svelte';
  import { invalidateAll } from '$app/navigation';

  let { data } = $props();

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

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

To enable real-time for a table, run this in the SQL editor:

ALTER PUBLICATION supabase_realtime ADD TABLE tasks;

Now, if you open the app in two tabs and add a task in one, it appears instantly in the other. This pattern is powerful for collaborative features, dashboards, and live data feeds.

Row Level Security Patterns

RLS is what makes it safe to expose your Supabase anon key to the browser. Here are common patterns beyond the basic "users see their own data" example:

Team-Based Access

-- Users can see tasks from their team
CREATE POLICY "Team members can view tasks"
  ON tasks FOR SELECT
  USING (
    team_id IN (
      SELECT team_id FROM team_members
      WHERE user_id = auth.uid()
    )
  );

Public and Private Data

-- Anyone can read published posts, only authors can read drafts
CREATE POLICY "Public posts are visible to all"
  ON posts FOR SELECT
  USING (published = true OR author_id = auth.uid());

Admin Override

-- Admins can do anything
CREATE POLICY "Admins have full access"
  ON tasks FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM profiles
      WHERE id = auth.uid() AND role = 'admin'
    )
  );

Deploying Your SvelteKit + Supabase App

Once your app is ready, deployment is straightforward. SvelteKit supports multiple deployment targets through adapters.

For Vercel, install the adapter and deploy:

npm install @sveltejs/adapter-vercel

Update svelte.config.js:

import adapter from '@sveltejs/adapter-vercel';

export default {
  kit: {
    adapter: adapter()
  }
};

Set your Supabase environment variables in the Vercel dashboard and deploy. Your Supabase project is already hosted, so there is nothing else to configure on that side.

For other hosting options, SvelteKit supports adapters for Cloudflare Pages, Netlify, and Node.js. The Supabase configuration is the same regardless of where you host the SvelteKit app — just set the environment variables.

Teta handles deployment with a single click, connecting your SvelteKit project to Vercel and configuring environment variables automatically.

FAQ

Is Supabase free?

Supabase has a generous free tier that includes 500 MB of database storage, 1 GB of file storage, 50,000 monthly active users for authentication, and 500 MB of bandwidth. This is enough for development, prototyping, and small production apps. Paid plans start at $25/month for the Pro tier with higher limits and additional features like daily backups and email support.

How do I connect SvelteKit to Supabase?

Install @supabase/supabase-js and @supabase/ssr, then create a Supabase client in your hooks.server.ts using createServerClient with your project URL and anon key. This makes the client available in all server load functions and form actions via locals.supabase. For client-side code, use createBrowserClient with the same credentials.

Can I use Supabase for authentication?

Yes, Supabase Auth is a full-featured authentication system that supports email/password, magic links, phone authentication, social login (Google, GitHub, Apple, Discord, and more), and multi-factor authentication. It integrates seamlessly with SvelteKit through the @supabase/ssr package, which handles cookie-based session management for server-rendered applications.

What about real-time features?

Supabase provides real-time subscriptions through PostgreSQL's replication stream. You can listen for INSERT, UPDATE, and DELETE events on any table and update your UI instantly. In SvelteKit, you subscribe on the client side using supabase.channel().on('postgres_changes', ...) and call invalidateAll() to refresh the page data when changes occur.

Frequently Asked Questions

Is Supabase free?

Supabase has a generous free tier that includes 500 MB of database storage, 1 GB of file storage, 50,000 monthly active users for authentication, and 500 MB of bandwidth. This is enough for development, prototyping, and small production apps. Paid plans start at $25/month for the Pro tier with higher limits and additional features like daily backups and email support.

How do I connect SvelteKit to Supabase?

Install @supabase/supabase-js and @supabase/ssr , then create a Supabase client in your hooks.server.ts using createServerClient with your project URL and anon key. This makes the client available in all server load functions and form actions via locals.supabase . For client-side code, use createBrowserClient with the same credentials.

Can I use Supabase for authentication?

Yes, Supabase Auth is a full-featured authentication system that supports email/password, magic links, phone authentication, social login (Google, GitHub, Apple, Discord, and more), and multi-factor authentication. It integrates seamlessly with SvelteKit through the @supabase/ssr package, which handles cookie-based session management for server-rendered applications.

What about real-time features?

Supabase provides real-time subscriptions through PostgreSQL's replication stream. You can listen for INSERT, UPDATE, and DELETE events on any table and update your UI instantly. In SvelteKit, you subscribe on the client side using supabase.channel().on('postgres_changes', ...) and call invalidateAll() to refresh the page data when changes occur.

Ready to start building?

Create your next web app with AI-powered development tools.

Start Building Free
← All articles