Skip to content

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

Terminal window
npm init -y
npm install @heliosdb/client ws
npm install -D typescript ts-node @types/node @types/ws
npx tsc --init

A Nano server with auth and HTTP enabled (default ports):

Terminal window
heliosdb-nano start \
--memory \
--auth scram-sha-256 \
--password s3cret

1. Connect

src/db.ts
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.sql
CREATE 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));
Terminal window
psql "postgresql://postgres:s3cret@127.0.0.1:5432/postgres" -f schema.sql

3. Auth — Sign Up + Login

src/auth.ts
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/signup
  • POST /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

src/posts.ts
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.desc
Authorization: Bearer <jwt>

5. Subscribe to Row-Level Changes (Realtime)

src/realtime.ts
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

src/app.ts
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:

Terminal window
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

// Update
await 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 filters
await db.from("posts")
.select("*")
.gte("created_at", "2026-04-01")
.ilike("title", "%nano%")
.order("created_at", { ascending: false })
.range(0, 9); // first 10

The 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

SymptomCauseFix
401 Unauthorized after loginJWT expired (default 1h)Refresh: db.auth.refreshSession()
Empty array but row existsRLS policy excludes itEXPLAIN (rls, costs off) to see policy plan; check current_setting('jwt.email')
WebSocket disconnects immediatelyServer not running with HTTP enabledConfirm start (not dump/restore); HTTP is on by default at port 8080
db.from is not a functionWrong packageConfirm @heliosdb/client (not the generic heliosdb-nano)
Update returns 0 rows affectedRLS blocks the row even from updateSame JWT must satisfy USING and WITH CHECK clauses

Where Next