Use SupaHost from a Node.js server
Call a SupaHost project from Node.js with supabase-js. Server client options, an admin client with the service_role key, per-request clients that respect RLS, an Express example and Realtime in Node.
Many apps have a backend of their own: an Express or Fastify API, a worker, a cron script. supabase-js works in Node.js the same way it works in the browser, over HTTPS, so it needs no database connection and no pooler. That's the right fit for SupaHost, where Postgres isn't exposed to the internet on most plans.
On a server you usually need two kinds of client: an admin client that bypasses Row Level Security for trusted jobs, and a per-request client that acts as the user who called your API.
The examples use the recipes table from the quickstart.
Install and configure
Use Node.js 20 or later.
npm install @supabase/supabase-js
Set the values from your project's Connect tab (subtab App) as environment variables on the server:
SUPABASE_URL=https://app-one.acme.supahost.space
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...
The service_role key is shown after you click Reveal, and only to the project owner. Treat it like a database password.
An admin client
// lib/supabase-admin.ts
import { createClient } from '@supabase/supabase-js'
export const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } }
)
The auth options turn off session storage and refresh, which only make sense in a browser. Use this client for work that isn't on behalf of a particular user:
import { admin } from './lib/supabase-admin'
// Nightly cleanup: remove recipes nobody owns.
const { count, error } = await admin
.from('recipes')
.delete({ count: 'exact' })
.is('user_id', null)
if (error) throw error
console.log(`deleted ${count} orphaned recipes`)
// Create a user without sending a confirmation email.
await admin.auth.admin.createUser({
email: '[email protected]',
password: 'a-long-password',
email_confirm: true,
})
Because it skips Row Level Security, never run a user-supplied query through the admin client without checking who is asking.
A client per request
When a signed-in user calls your API, have the frontend send their access token (session.access_token in supabase-js) in the Authorization header. Your server verifies it and queries as that user, so the same policies apply as in the browser.
// server.ts
import express from 'express'
import { createClient } from '@supabase/supabase-js'
const url = process.env.SUPABASE_URL!
const anonKey = process.env.SUPABASE_ANON_KEY!
function clientForToken(token: string) {
return createClient(url, anonKey, {
global: { headers: { Authorization: `Bearer ${token}` } },
auth: { persistSession: false, autoRefreshToken: false },
})
}
const app = express()
app.use(express.json())
app.post('/api/recipes', async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer /, '')
if (!token) return res.status(401).json({ error: 'missing token' })
const supabase = clientForToken(token)
const { data: { user }, error: authError } = await supabase.auth.getUser(token)
if (authError || !user) return res.status(401).json({ error: 'invalid token' })
const { data, error } = await supabase
.from('recipes')
.insert({ title: req.body.title, minutes: req.body.minutes, user_id: user.id })
.select()
.single()
if (error) return res.status(400).json({ error: error.message })
res.status(201).json(data)
})
app.listen(3001)
getUser(token) asks Auth to confirm the token, so a forged or expired token is rejected. Create the client inside the handler, not once at startup, so requests from different users never share headers.
On the frontend:
const { data: { session } } = await supabase.auth.getSession()
await fetch('/api/recipes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({ title: 'Lemon rice', minutes: 30 }),
})
If your server renders pages and keeps the session in cookies instead, use @supabase/ssr; see Server-side auth and SSR.
Calling Edge Functions and Storage
const { data, error } = await admin.functions.invoke('estimate-cost', {
body: { recipeId: 1 },
})
const { data: file } = await admin.storage.from('photos').download('soup/cover.jpg')
Functions live at https://app-one.acme.supahost.space/functions/v1/<name>.
Realtime in Node
Node.js 22 and later have a built-in WebSocket, which Realtime uses. On Node.js 20, install ws and pass it in:
import ws from 'ws'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
realtime: { transport: ws },
})
supabase
.channel('kitchen')
.on('broadcast', { event: 'order' }, ({ payload }) => console.log('new order', payload))
.subscribe()
What about pg, Prisma or Drizzle?
Drivers and ORMs that open a Postgres connection need direct database access. On SupaHost that's only on Business and Enterprise, through the SSH tunnel, which is meant for developer tools and migrations rather than a production app server. For application queries, use supabase-js. See Prisma and Drizzle ORM for how to use them for schema work.
Serverless platforms are fine with supabase-js: each call is a plain HTTPS request, so there are no connection limits to manage.