A Supabase-backed static CMS starter for Astro
How this site uses Supabase as a small content backend while still deploying as a fast static Astro site.
A static site does not have to mean hardcoded content forever. For small portfolios, editorial sites, and marketing surfaces, I like a simple pattern: use Supabase as the content source, fetch published rows at build time, and ship static HTML.
That gives you a real database and editorial workflow without adding runtime server complexity to every page request.
The content model
For this site, the model is intentionally small. A journal_posts table stores the editorial surface:
create table public.journal_posts (
id uuid primary key default gen_random_uuid(),
slug text not null unique,
title text not null,
excerpt text not null default '',
body_md text not null default '',
post_type text not null default 'article',
topic text,
read_time_minutes integer not null default 5,
published_at timestamptz,
status text not null default 'draft',
featured boolean not null default false,
cover_kind text not null default 'lines',
sort_order integer not null default 100,
metadata jsonb not null default '{}'::jsonb
);
This is not a full CMS. That is the point. The table stores enough to publish articles and render a journal index without forcing the site into a heavy admin architecture.
RLS should match the publishing model
Public visitors should only read published posts. Drafts should not leak into the build or the browser.
create policy "Published journal posts are public"
on public.journal_posts
for select
to anon, authenticated
using (
status = 'published'
and published_at is not null
and published_at <= now()
);
This policy is simple, but it matters. If the table is exposed through the Supabase Data API, row-level security is the line between public content and draft content.
Fetch content during static generation
Astro can fetch posts during getStaticPaths, generate one route per slug, and convert Markdown into HTML.
export async function getStaticPaths() {
const posts = await getPublishedJournalPosts();
return posts.map((post) => ({ params: { slug: post.slug } }));
}
At build time, the site asks Supabase for published posts. At runtime, users request static files. That means the public site does not need to keep a database connection open for every visitor.
Keep bundled fallback content
I still keep fallback content in the repository. This protects local development and deploy previews.
If Supabase is unreachable, the site can still build. If a content migration is in progress, the preview is not blocked. If the database row is accidentally deleted, the core portfolio does not collapse into an empty journal.
Fallback content is not a replacement for a CMS. It is a resilience layer.
Published rows drive production content.
Bundled content keeps previews and local dev working.
Generated HTML is cacheable and SEO-friendly.
Understand the tradeoff
The tradeoff is rebuild timing. If you edit content in Supabase, the deployed static site needs a new build before users see the change.
For a personal site, portfolio, or small editorial surface, that is usually fine. Content changes are not happening every minute. The benefits are significant: simpler hosting, fewer runtime failure modes, excellent caching, and predictable SEO output.
If you need real-time previews, logged-in editorial workflows, or high-frequency publishing, you may want a server-rendered approach or a dedicated CMS. But do not start there by default.
The deployment checklist
Before shipping a Supabase-backed static CMS, check:
- The build has
PUBLIC_SUPABASE_URL. - The build has a public publishable or anon key.
- RLS is enabled on exposed tables.
- Public policies only expose published content.
- Drafts have
status = draftor futurepublished_atvalues. - Markdown rendering is safe for your authoring model.
- Fallback content exists for local development.
- A rebuild path exists after content edits.
When I would not use this pattern
This pattern is not right for every site. If editors need instant previews, scheduled publishing without rebuild hooks, complex media workflows, permissions inside an admin UI, or user-generated content, a static build-time CMS may become frustrating.
It is also not ideal when content changes many times per hour. Static rebuilds are fast, but they are still rebuilds. At some publishing frequency, server rendering or an edge-cached runtime route becomes a better fit.
For a personal site, portfolio, agency site, or small resource library, the tradeoff is usually excellent. The editorial model is real, the deployed site is simple, and SEO gets complete HTML.
A note on ownership
The most important architectural question is not technical. It is ownership. Who can edit content? Who can publish? Who can change the schema? Who knows how to trigger a rebuild? If the answer to those questions is unclear, the CMS will feel broken even if the code is correct.
I keep the workflow narrow: content lives in Supabase, public reads are controlled by RLS, the site builds static pages, and fallback content protects the repo. That is enough structure for this kind of site.
Keep authoring boring
The authoring experience should be intentionally boring at first. A title, excerpt, markdown body, status, publish date, and sort order are enough for many sites. Add complexity only after the workflow proves it needs it.
For example, do not add categories, series, custom social images, related links, and preview states on day one if the site only has a few posts. Those fields create editorial overhead. Start with the fields that affect publishing, rendering, and SEO. Then add richer structure when the content library grows.
This restraint keeps the CMS from becoming a side project larger than the site.
The important part is choosing the smallest architecture that matches the publishing workflow. For this site, build-time Supabase content is enough: real content management, static delivery, and fewer moving parts than a runtime CMS.