# 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.

Source: https://supahost.app/docs/getting-started/node-server · Updated 2026-11-11

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](/docs/getting-started/quickstart).

## Install and configure

Use Node.js 20 or later.

```bash
npm install @supabase/supabase-js
```

Set the values from your project's **Connect** tab (subtab **App**) as environment variables on the server:

```bash
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

```ts
// 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:

```ts
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: 'chef@example.com',
  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.

```ts
// 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:

```ts
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](/docs/auth/server-side).

## Calling Edge Functions and Storage

```ts
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:

```ts
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](/docs/platform/ssh), which is meant for developer tools and migrations rather than a production app server. For application queries, use supabase-js. See [Prisma](/docs/integrations/prisma) and [Drizzle ORM](/docs/integrations/drizzle) 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.

## Next steps

- [Admin tasks on the server with the service_role key](/kb/server-side-admin-tasks)
- [Managing users (admin API)](/docs/auth/user-management)
- [Keeping keys safe](/docs/security/api-key-safety)
- [Session missing in fetch requests to your API](/kb/session-missing-in-fetch)
