Supabase
Supabase provides Postgres, authentication, and file storage. In Pulse, all Supabase queries run in server fetchers — credentials stay on the server, query results are filtered before serialisation, and guard enforces session checks before any data is fetched.
Setup
npm install @supabase/supabase-js
# .env
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_KEY=your-service-role-key # server-side only
Create two client helpers — one for public queries (respects Row Level Security), one for admin operations:
// src/lib/supabase.js
import { createClient } from '@supabase/supabase-js'
const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY } = process.env
// Public client — use in server fetchers for user-scoped queries
export function supabase(accessToken) {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: accessToken
? { headers: { Authorization: `Bearer ${accessToken}` } }
: {},
})
return client
}
// Admin client — bypasses RLS; use only for trusted server operations
export const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
Querying data
Call Supabase inside server fetchers — they run on the server before every render. The result is passed to view as the second argument.
// src/pages/posts.js
import { supabase } from '../lib/supabase.js'
import { escHtml } from '@invisibleloop/pulse/html'
export default {
route: '/posts',
server: {
posts: async () => {
const { data, error } = await supabase().from('posts')
.select('id, title, slug, created_at')
.order('created_at', { ascending: false })
.limit(20)
if (error) throw new Error(error.message)
return data
},
},
state: {},
view: (_state, server) => `
<main id="main-content">
<h1>Posts</h1>
<ul>
${server.posts.map(p => `
<li><a href="/posts/${escHtml(p.slug)}">${escHtml(p.title)}</a></li>
`).join('')}
</ul>
</main>
`,
}
serverTtl to cache the Supabase query result in-process. A 60-second TTL on a public listing page means one database hit per minute across all visitors — Supabase stays fast even under load.Authentication
Supabase Auth issues a JWT access token and a refresh token on login. Store both in httpOnly cookies — they are never accessible to JavaScript and survive page navigations.
Login page
// src/pages/auth/login.js
import { supabase } from '../../lib/supabase.js'
import { escHtml } from '@invisibleloop/pulse/html'
export default {
route: '/login',
guard: async (ctx) => {
// Already logged in — send to dashboard
if (ctx.cookies.access_token) return { redirect: '/dashboard' }
},
state: { status: 'idle', error: '' },
view: (state) => `
<main id="main-content">
<h1>Sign in</h1>
<form data-action="login">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">
<label for="password">Password</label>
<input id="password" name="password" type="password" required autocomplete="current-password">
${state.error ? `<p role="alert">${escHtml(state.error)}</p>` : ''}
<button type="submit">
${state.status === 'loading' ? 'Signing in…' : 'Sign in'}
</button>
</form>
</main>
`,
actions: {
login: {
onStart: () => ({ status: 'loading', error: '' }),
run: async (_state, _server, formData) => {
const { data, error } = await supabase().auth.signInWithPassword({
email: formData.get('email'),
password: formData.get('password'),
})
if (error) throw new Error(error.message)
return data.session
},
onSuccess: (state, session, ctx) => {
const opts = { httpOnly: true, sameSite: 'Lax', path: '/' }
ctx.setCookie('access_token', session.access_token, { ...opts, maxAge: 3600 })
ctx.setCookie('refresh_token', session.refresh_token, { ...opts, maxAge: 604800 })
ctx.setHeader('Location', '/dashboard')
return { status: 'success' }
},
onError: (_state, err) => ({
status: 'idle',
error: err.message || 'Sign in failed',
}),
},
},
}
Protecting routes
Use guard to verify the session before any server data is fetched. Pass the token to your fetchers so Supabase enforces Row Level Security for that user.
// src/pages/dashboard.js
import { supabase, admin } from '../lib/supabase.js'
export default {
route: '/dashboard',
guard: async (ctx) => {
const token = ctx.cookies.access_token
if (!token) return { redirect: '/login' }
// Verify the token is still valid
const { error } = await supabase(token).auth.getUser()
if (error) return { redirect: '/login' }
},
server: {
// Token is available in guard ctx — pass it through server state or
// re-read from cookies in the fetcher
profile: async (ctx) => {
const { data } = await supabase(ctx.cookies.access_token)
.from('profiles')
.select('name, plan')
.single()
return data
},
},
state: {},
view: (_state, server) => `
<main id="main-content">
<h1>Dashboard</h1>
<p>Welcome, ${server.profile.name}</p>
</main>
`,
}
auth.uid() = user_id means a bug in your server code cannot accidentally expose another user's data — the database rejects the query before it returns anything.Logout
// src/pages/auth/logout.js
export default {
route: '/logout',
contentType: 'text/html',
render: (ctx) => {
const opts = { httpOnly: true, sameSite: 'Lax', path: '/', maxAge: 0 }
ctx.setCookie('access_token', '', opts)
ctx.setCookie('refresh_token', '', opts)
return { redirect: '/login' }
},
}
Sign up
// src/pages/auth/signup.js
import { supabase } from '../../lib/supabase.js'
import { escHtml } from '@invisibleloop/pulse/html'
export default {
route: '/signup',
state: { status: 'idle', error: '' },
view: (state) => `
<main id="main-content">
<h1>Create account</h1>
${state.status === 'success'
? '<p role="status">Check your email to confirm your account.</p>'
: `
<form data-action="signup">
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="8">
${state.error ? `<p role="alert">${escHtml(state.error)}</p>` : ''}
<button type="submit">
${state.status === 'loading' ? 'Creating account…' : 'Create account'}
</button>
</form>
`}
</main>
`,
actions: {
signup: {
onStart: () => ({ status: 'loading', error: '' }),
run: async (_state, _server, formData) => {
const { error } = await supabase().auth.signUp({
email: formData.get('email'),
password: formData.get('password'),
})
if (error) throw new Error(error.message)
},
onSuccess: () => ({ status: 'success', error: '' }),
onError: (_state, err) => ({ status: 'idle', error: err.message }),
},
},
}
File storage
Upload files to Supabase Storage from an action. The file arrives in FormData — convert it to an ArrayBuffer and pass it to the storage client.
// src/pages/upload.js
import { admin } from '../lib/supabase.js'
import { escHtml } from '@invisibleloop/pulse/html'
export default {
route: '/upload',
state: { status: 'idle', url: '', error: '' },
view: (state) => `
<main id="main-content">
<h1>Upload file</h1>
<form data-action="upload" enctype="multipart/form-data">
<input name="file" type="file" accept="image/*" required>
<button type="submit">
${state.status === 'loading' ? 'Uploading…' : 'Upload'}
</button>
</form>
${state.url ? `<img src="${escHtml(state.url)}" alt="Uploaded file" width="400" height="300">` : ''}
${state.error ? `<p role="alert">${escHtml(state.error)}</p>` : ''}
</main>
`,
actions: {
upload: {
onStart: () => ({ status: 'loading', error: '' }),
run: async (_state, _server, formData) => {
const file = formData.get('file')
const buffer = await file.arrayBuffer()
const ext = file.name.split('.').pop()
const path = `${crypto.randomUUID()}.${ext}`
const { error } = await admin.storage
.from('uploads')
.upload(path, buffer, { contentType: file.type })
if (error) throw new Error(error.message)
const { data } = admin.storage.from('uploads').getPublicUrl(path)
return data.publicUrl
},
onSuccess: (_state, url) => ({ status: 'idle', url }),
onError: (_state, err) => ({ status: 'idle', error: err.message }),
},
},
}
Row Level Security
Always enable RLS on tables that hold user data. A minimal policy that lets users read only their own rows:
-- Enable RLS on the table
alter table posts enable row level security;
-- Users can only select their own posts
create policy "users read own posts"
on posts for select
using (auth.uid() = user_id);
-- Users can only insert rows for themselves
create policy "users insert own posts"
on posts for insert
with check (auth.uid() = user_id);