Node.js BaaS App — REST + Realtime End-to-End
Node.js BaaS App — REST + Realtime End-to-End
Available since: v3.8.0 (BaaS REST + Realtime stable)
Stack: TypeScript + @heliosdb/client (Supabase-compatible fluent API) + ws
HeliosDB build: default — no feature flag required
UVP
Building a real app on top of an embedded database usually means writing a server tier just to expose CRUD over HTTP, then bolting Socket.IO on top for live updates. HeliosDB Nano ships both in the same binary: a PostgREST-compatible REST surface at /rest/v1/{table} and a Supabase-style WebSocket channel at /realtime/v1/websocket. The official @heliosdb/client npm package gives you the same fluent db.from('table').select() API you already know, plus row-level subscriptions. One npm install. One createClient call. Auth + REST + Realtime + RLS, in TypeScript.
Prerequisites
npm init -ynpm install @heliosdb/client wsnpm install -D typescript ts-node @types/node @types/wsnpx tsc --initA Nano server with auth and HTTP enabled (default ports):
heliosdb-nano start \ --memory \ --auth scram-sha-256 \ --password s3cret1. Connect
import { createClient } from "@heliosdb/client";
export const db = createClient( "http://localhost:8080", "anon-key" // any string — used for the bearer token before login);The client speaks the same fluent API as @supabase/supabase-js; if you’ve used Supabase before, the surface is identical.
2. Schema (one-time, via SQL)
Schema management still goes through SQL. Use any PostgreSQL client — psql, pgAdmin, or a .sql file at boot:
-- schema.sqlCREATE TABLE IF NOT EXISTS posts ( id SERIAL PRIMARY KEY, author TEXT NOT NULL, title TEXT NOT NULL, body TEXT, created_at TIMESTAMP DEFAULT now());
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY posts_owner_only ON posts USING (author = current_setting('jwt.email', true));psql "postgresql://postgres:s3cret@127.0.0.1:5432/postgres" -f schema.sql3. Auth — Sign Up + Login
import { db } from "./db.js";
export async function signup(email: string, password: string) { const { data, error } = await db.auth.signUp({ email, password }); if (error) throw error; return data;}
export async function login(email: string, password: string) { const { data, error } = await db.auth.signInWithPassword({ email, password }); if (error) throw error; // data.session.access_token is now attached to every subsequent request return data.session;}Behind the scenes the client is hitting:
POST /auth/v1/signupPOST /auth/v1/token?grant_type=password
Both endpoints are part of the BaaS surface; see BAAS_REST_API_TUTORIAL for the full curl-level reference.
4. Insert via REST
import { db } from "./db.js";
export async function createPost(title: string, body: string) { const { data, error } = await db .from("posts") .insert({ title, body }) .select() .single(); if (error) throw error; return data;}
export async function listMyPosts() { // RLS filters automatically — only rows where author == JWT email come back const { data, error } = await db .from("posts") .select("*") .order("created_at", { ascending: false }); if (error) throw error; return data;}
export async function searchPosts(term: string) { return db.from("posts") .select("id, title, body") .ilike("title", `%${term}%`) .limit(20);}The fluent chain compiles down to a single REST call:
GET /rest/v1/posts?select=*&order=created_at.descAuthorization: Bearer <jwt>5. Subscribe to Row-Level Changes (Realtime)
import { db } from "./db.js";
export function watchPosts(onChange: (evt: any) => void) { const channel = db .channel("posts-changes") .on( "postgres_changes", { event: "*", schema: "public", table: "posts" }, (payload) => { console.log("[realtime]", payload.eventType, payload.new ?? payload.old); onChange(payload); } ) .subscribe(); return () => channel.unsubscribe();}Under the hood this opens a WebSocket to ws://localhost:8080/realtime/v1/websocket, joins a topic, and emits INSERT / UPDATE / DELETE envelopes shaped identically to Supabase’s. RLS applies on the realtime channel too — clients only see rows their JWT can read.
6. Wire it Together
import { signup, login } from "./auth.js";import { createPost, listMyPosts } from "./posts.js";import { watchPosts } from "./realtime.js";
async function main() { // Create + login await signup("alice@example.com", "s3cret"); const session = await login("alice@example.com", "s3cret"); console.log("logged in as", session.user.email);
// Subscribe before writing so we observe our own insert const stop = watchPosts((evt) => console.log("change:", evt.eventType));
// Insert const post = await createPost("Hello Nano", "First post over BaaS"); console.log("inserted:", post);
// Read back — RLS clips to alice's rows const mine = await listMyPosts(); console.log("mine:", mine);
setTimeout(() => { stop(); process.exit(0); }, 1000);}
main().catch((e) => { console.error(e); process.exit(1); });Run:
npx ts-node src/app.ts# logged in as alice@example.com# [realtime] INSERT { id: 1, author: 'alice@example.com', title: 'Hello Nano', ... }# inserted: { id: 1, ... }# mine: [ { id: 1, ... } ]7. Update / Delete with the Same Fluent API
// Updateawait db.from("posts") .update({ body: "edited" }) .eq("id", post.id) .select();
// Delete (RLS-protected — alice can only delete her own rows)await db.from("posts") .delete() .eq("id", post.id);
// Compound filtersawait db.from("posts") .select("*") .gte("created_at", "2026-04-01") .ilike("title", "%nano%") .order("created_at", { ascending: false }) .range(0, 9); // first 10The 20 PostgREST filter operators (eq, gt, gte, lt, lte, like, ilike, in, is, cs, cd, ov, fts, plfts, phfts, wfts, not, or, and, neq) all map to chain methods.
8. RLS-Aware Reads
The JWT from login() is attached to every request via Authorization: Bearer <jwt>. The Nano server unpacks it and exposes the claims to your RLS policies via current_setting('jwt.<claim>', true).
current_setting('jwt.email', true)—'alice@example.com'current_setting('jwt.sub', true)— UUID- Any custom claim added at signup
So policies like USING (tenant_id = current_setting('jwt.tenant_id')) Just Work without writing a single line of session-context plumbing on the server.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized after login | JWT expired (default 1h) | Refresh: db.auth.refreshSession() |
| Empty array but row exists | RLS policy excludes it | EXPLAIN (rls, costs off) to see policy plan; check current_setting('jwt.email') |
| WebSocket disconnects immediately | Server not running with HTTP enabled | Confirm start (not dump/restore); HTTP is on by default at port 8080 |
db.from is not a function | Wrong package | Confirm @heliosdb/client (not the generic heliosdb-nano) |
Update returns 0 rows affected | RLS blocks the row even from update | Same JWT must satisfy USING and WITH CHECK clauses |
Where Next
- BAAS_REST_API_TUTORIAL — every REST verb at the curl level.
- REALTIME_WEBSOCKET — channel topology, presence, broadcast.
- TypeScript SDK reference — full fluent API surface.
- PYTHON_VECTOR_APP — semantic search end-to-end in Python.