Astro with Supabase Tutorial: Build Full-Stack Apps the Modern Way
Astro is a modern web framework that ships zero JavaScript by default, giving you blazing-fast static sites with the option to add server-side rendering (SSR) only where you need it. Supabase is an open-source Firebase alternative built on PostgreSQL, offering authentication, real-time subscriptions, storage, and edge functions out of the box.
Together, they form one of the most productive stacks for building content-driven web applications in 2026. In this tutorial, you will learn how to integrate Astro with Supabase from scratch, including database queries, authentication, SSR pages, Row Level Security, and real-time features.
Why Combine Astro with Supabase?
Before diving into code, let's understand why this pairing works so well.
Astro gives you performance by default. Most pages on a content site don't need client-side JavaScript. Astro renders everything to static HTML at build time, and you only opt into SSR for pages that require dynamic data or authentication. This hybrid approach means your blog loads in milliseconds while your admin dashboard still gets full server-side capabilities.
Supabase gives you a production-ready backend in minutes. Instead of building a REST API from scratch, you get an auto-generated API layer on top of PostgreSQL, built-in auth with dozens of OAuth providers, row-level security policies, and real-time websocket subscriptions. The JavaScript client library handles all of this with a clean, chainable API.
The combination eliminates boilerplate. You don't need to build user management, write SQL migration tooling, or set up a separate API server. Supabase handles the backend; Astro handles the frontend. You write the glue code and focus on your actual product.
Getting Started: Project Setup
Step 1: Create a New Astro Project
Open your terminal and scaffold a fresh Astro project:
npm create astro@latest my-astro-supabase-app
cd my-astro-supabase-app
Choose the "Empty" template when prompted. Select TypeScript for type safety — it plays well with Supabase's generated types.
Step 2: Install Supabase Dependencies
You need two packages: the core Supabase client and the SSR helper:
npm install @supabase/supabase-js @supabase/ssr
The @supabase/ssr package is essential for server-side authentication. It handles cookie-based session management, which is required when you're not running in a browser environment.
Step 3: Configure Environment Variables
Create a .env file in your project root:
PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
The PUBLIC_ prefix makes variables available in client-side code. The service role key should never be exposed to the browser — it bypasses Row Level Security and is only used in server-side code.
Step 4: Create the Supabase Client
Create a file at src/lib/supabase.ts:
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
// Public client — respects RLS policies
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Admin client — bypasses RLS, server-side only
export const supabaseAdmin = createClient(
supabaseUrl,
import.meta.env.SUPABASE_SERVICE_ROLE_KEY
);
This gives you two clients: one for public-facing queries that respect your security policies, and one for server-side operations that need full database access.
Step 5: Enable SSR in Astro
Update your astro.config.mjs to support server-side rendering:
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "static",
adapter: node({ mode: "standalone" }),
});
With output: "static", Astro pre-renders all pages by default. You can then opt individual pages into SSR by adding export const prerender = false at the top of any .astro file. This hybrid approach is ideal — static pages for content, SSR pages for authenticated features.
Querying Data from Supabase
Let's fetch data from a posts table and display it on an Astro page.
Static Page Example (Pre-rendered at Build Time)
Create src/pages/blog.astro:
---
import { supabase } from "../lib/supabase";
const { data: posts, error } = await supabase
.from("posts")
.select("id, title, slug, excerpt, published_at")
.eq("status", "published")
.order("published_at", { ascending: false })
.limit(20);
if (error) {
console.error("Failed to fetch posts:", error.message);
}
---
<html lang="en">
<body>
<h1>Blog</h1>
{posts?.map((post) => (
<article>
<h2>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</h2>
<p>{post.excerpt}</p>
<time datetime={post.published_at}>
{new Date(post.published_at).toLocaleDateString()}
</time>
</article>
))}
</body>
</html>
This page is pre-rendered at build time. The Supabase query runs once during the build, and the resulting HTML is served as a static file. Fast and cacheable.
Dynamic Page Example (SSR)
For pages that need fresh data on every request, add prerender = false:
---
export const prerender = false;
import { supabase } from "../lib/supabase";
const slug = Astro.params.slug;
const { data: post, error } = await supabase
.from("posts")
.select("*")
.eq("slug", slug)
.single();
if (error || !post) {
return Astro.redirect("/404");
}
---
<html lang="en">
<body>
<article>
<h1>{post.title}</h1>
<div set:html={post.content} />
</article>
</body>
</html>
Authentication with Supabase SSR
Authentication in an SSR context requires cookie-based session management. Here's how to set it up properly.
Create the Server-Side Supabase Client
Create src/lib/supabase-server.ts:
import { createServerClient } from "@supabase/ssr";
import type { AstroCookies } from "astro";
export function createSupabaseServerClient(cookies: AstroCookies) {
return createServerClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
return cookies.headers
? Object.entries(cookies.headers)
.map(([name, value]) => ({ name, value }))
: [];
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookies.set(name, value, {
...options,
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
});
},
},
}
);
}
Build Auth Endpoints
Create src/pages/api/auth/signin.ts:
import type { APIRoute } from "astro";
import { createSupabaseServerClient } from "../../../lib/supabase-server";
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const formData = await request.formData();
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
if (!email || !password) {
return new Response("Email and password are required", { status: 400 });
}
const supabase = createSupabaseServerClient(cookies);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return new Response(error.message, { status: 401 });
}
return redirect("/dashboard");
};
Protect Routes with Middleware
Create src/middleware.ts:
import { defineMiddleware } from "astro:middleware";
import { createSupabaseServerClient } from "./lib/supabase-server";
const protectedRoutes = ["/dashboard", "/admin"];
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = context.url;
const isProtected = protectedRoutes.some((route) =>
pathname.startsWith(route)
);
if (isProtected) {
const supabase = createSupabaseServerClient(context.cookies);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return context.redirect("/login");
}
context.locals.user = user;
}
return next();
});
This middleware checks authentication on every request to protected routes and redirects unauthenticated users to the login page.
Deep Dive: Row Level Security Integration
One of the most powerful aspects of the Astro + Supabase stack is how Row Level Security (RLS) works with your frontend.
RLS policies run at the database level, meaning even if someone bypasses your frontend, they can't access data they shouldn't. Here's an example policy for a posts table:
-- Anyone can read published posts
CREATE POLICY "Public can read published posts"
ON posts FOR SELECT
USING (status = 'published');
-- Only the author can update their own posts
CREATE POLICY "Authors can update own posts"
ON posts FOR UPDATE
USING (auth.uid() = author_id);
-- Only authenticated users can insert posts
CREATE POLICY "Authenticated users can create posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = author_id);
When you use the anon client in your Astro pages, these policies are automatically enforced. The user's JWT (stored in cookies via @supabase/ssr) tells Supabase who is making the request, and the database filters results accordingly.
This eliminates an entire category of security bugs. You don't need to write authorization checks in your API routes — the database handles it.
Real-Time Features
Supabase's real-time engine lets you subscribe to database changes over WebSockets. This is useful for features like live comments, notifications, or collaborative editing.
Here's how to add real-time updates in an Astro component using a client-side framework island:
// src/components/LiveComments.tsx (React island)
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
interface Comment {
id: string;
content: string;
created_at: string;
user_name: string;
}
export default function LiveComments({ postId }: { postId: string }) {
const [comments, setComments] = useState<Comment[]>([]);
useEffect(() => {
// Fetch existing comments
supabase
.from("comments")
.select("*")
.eq("post_id", postId)
.order("created_at", { ascending: true })
.then(({ data }) => {
if (data) setComments(data);
});
// Subscribe to new comments
const channel = supabase
.channel(`comments:${postId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "comments",
filter: `post_id=eq.${postId}`,
},
(payload) => {
setComments((prev) => [...prev, payload.new as Comment]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [postId]);
return (
<div>
<h3>Comments</h3>
{comments.map((comment) => (
<div key={comment.id}>
<strong>{comment.user_name}</strong>
<p>{comment.content}</p>
</div>
))}
</div>
);
}
Use it in your Astro page with the client:load directive to hydrate the component:
---
import LiveComments from "../components/LiveComments";
---
<LiveComments client:load postId={post.id} />
Without client:load, the React component would render as static HTML with no interactivity — a common mistake when working with Astro's island architecture.
Common Mistakes to Avoid
1. Using the Wrong Supabase Client
The most frequent mistake is using createClient instead of createServerClient in SSR contexts. The standard client stores sessions in localStorage, which doesn't exist on the server. Always use @supabase/ssr for server-side code.
2. Exposing the Service Role Key
The service role key bypasses all RLS policies. Never use it in client-side code or expose it through PUBLIC_ environment variables. It should only exist in server-side API routes and build scripts.
3. Forgetting to Enable SSR
If you try to use Astro.cookies or Astro.redirect on a pre-rendered page, Astro will throw an error. Any page that handles authentication or reads cookies must have export const prerender = false.
4. Not Setting Up the Auth Callback
OAuth and magic link flows redirect users back to your app with a code parameter. You need an API route to exchange that code for a session. Without it, authentication silently fails and users see a blank page.
5. Disabling RLS in Production
It's tempting to disable RLS during development to simplify queries. Never leave it disabled in production. Instead, write proper policies from the start — it saves you from catastrophic data leaks later.
6. Sharing Client Instances Across Requests
In SSR, a single Supabase client instance can leak session data between different users if requests are handled concurrently. Always create a new server client per request using createServerClient.
7. Missing Client Directives on Interactive Components
Astro ships zero JavaScript by default. If you embed a React, Vue, or Svelte component that interacts with Supabase on the client side, you must add a client:load or client:visible directive. Without it, the component renders as static HTML.
Frequently Asked Questions
Can I use Astro with Supabase in static-only mode?
Yes. If you don't need authentication or real-time features, you can query Supabase at build time and generate a fully static site. The data is fetched once during npm run build and baked into HTML.
Do I need the @supabase/ssr package?
Only if you're using authentication with SSR. For simple data fetching (reads only), the standard @supabase/supabase-js client works fine. The SSR package adds cookie-based session management.
How do I generate TypeScript types from my Supabase schema?
Run the Supabase CLI command:
npx supabase gen types typescript --project-id your-project-ref > src/types/database.ts
Then pass the generated types to your client:
import type { Database } from "./types/database";
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient<Database>(url, key);
This gives you full autocompletion and type checking on all your database queries.
Is Supabase free to use with Astro?
Supabase offers a generous free tier that includes 500 MB of database storage, 50,000 monthly active users for auth, 1 GB of file storage, and 2 million edge function invocations. This is more than enough for most personal projects and early-stage products.
How do I handle errors from Supabase queries?
Always check the error object returned by Supabase queries. In SSR pages, you can redirect to an error page. In API routes, return appropriate HTTP status codes:
const { data, error } = await supabase.from("posts").select("*");
if (error) {
console.error("Database error:", error.message);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
Conclusion
Astro and Supabase together give you the best of both worlds: a fast, lightweight frontend framework and a full-featured backend platform. You get static-site performance for content pages, server-side rendering for authenticated features, and a PostgreSQL database with built-in security through Row Level Security.
The key takeaways from this tutorial:
- Use
@supabase/ssrfor authentication in SSR pages — never the standard client. - Leverage Astro's hybrid rendering — pre-render content pages, use SSR only where needed.
- Enable RLS from day one — it's your last line of defense against data leaks.
- Create a new Supabase server client per request to avoid session leakage.
- Use island architecture with
client:loadfor interactive components that need Supabase on the client side.
Start with a simple data-fetching page, add authentication when you need it, and layer in real-time features as your application grows. The stack scales from a personal blog to a full SaaS application without requiring you to swap out any core technology.