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
Setting an httpOnly cookie requires a server response header — something a client-side action can never touch, since actions run entirely in the browser and only return state. Use spec.submit instead: it's a plain server-side POST handler with a real ctx, so it can call ctx.setCookie directly.
// 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: {},
submit: async (ctx) => {
const data = await ctx.formData()
const { data: authData, error } = await supabase().auth.signInWithPassword({
email: data?.email,
password: data?.password,
})
if (error) return { error: error.message, values: { email: data?.email ?? '' } }
const opts = { httpOnly: true, sameSite: 'Lax', path: '/' }
ctx.setCookie('access_token', authData.session.access_token, { ...opts, maxAge: 3600 })
ctx.setCookie('refresh_token', authData.session.refresh_token, { ...opts, maxAge: 604800 })
return { redirect: '/dashboard' }
},
view: (state, server) => `
<main id="main-content">
<h1>Sign in</h1>
<form method="POST">
${server.csrf}
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" value="${escHtml(server.form?.values?.email ?? '')}">
<label for="password">Password</label>
<input id="password" name="password" type="password" required autocomplete="current-password">
${server.form?.error ? `<p role="alert">${escHtml(server.form.error)}</p>` : ''}
<button type="submit">Sign in</button>
</form>
</main>
`,
}
${server.csrf} is required inside the form — a POST without the token is rejected with 403. This is enforced automatically for every spec.submit page; there is nothing to configure.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);