@weirdscience/based-client
v0.6.1
Published
SDK for Based — a minimal self-hosted BaaS. Typed client for any JavaScript app, plus React hooks.
Readme
@weirdscience/based-client
SDK for Based — a minimal self-hosted Backend-as-a-Service.
A typed data builder and auth helpers for any JavaScript app, plus React hooks for queries and mutations. Type-safe end-to-end when paired with based typegen.
Install
bun add @weirdscience/based-client
# or: npm install @weirdscience/based-client
# or: pnpm add @weirdscience/based-clientReact (>=18) is an optional peer dependency: only the root entry needs it. Projects without React import from @weirdscience/based-client/core (next section).
Without React — @weirdscience/based-client/core
The core entry point is the whole client minus the provider and hooks: createClient, from(), auth, BasedError and the types. It has no dependency on React and no "use client" directive, so it works in Vue, Svelte, Node, Bun, Deno, workers, or plain scripts.
import { createClient, BasedError } from "@weirdscience/based-client/core";
import type { Tables } from "./based";
const based = createClient<Tables>({
url: "https://my-app.based.example.com",
anonKey: "public-anon-key",
storage: false, // no localStorage outside the browser
});
await based.auth.signIn("[email protected]", "password123");
const { data: posts } = await based.from("posts").select({ order: "createdAt.desc", limit: 10 });
try {
await based.from("posts").insert({ title: "Hello" });
} catch (err) {
if (err instanceof BasedError && err.status === 403) {
// not allowed by the table's access policy
}
}Everything in the Data, Auth, Session persistence and Type safety sections applies to core unchanged. Only the Hooks need React.
Breaking changes in 0.5.0
mutate()resolves toRow | nulland no longer throws — check the returned row, or the hook'serror, instead of wrapping the call intry/catch.- A hook's
erroris aHookErrorobject ({ code, message, status?, details? }), not anError: readerror.message, and branch onerror.code. AuthUserhas arole("owner" | "member").signIn/signUpthrow aBasedErrorinstead of resolving with an error.
Quick start
Both bundles (ESM and CJS) ship with the "use client" directive, so
BasedProvider can be rendered straight from app/layout.tsx — no wrapper
component needed. The hooks are still client-side code: the file that calls a
hook needs its own "use client", and a Server Component cannot call one.
import { createClient, BasedProvider } from "@weirdscience/based-client";
const based = createClient({
url: process.env.NEXT_PUBLIC_BASED_URL!,
anonKey: process.env.NEXT_PUBLIC_BASED_ANON_KEY!,
});
export default function App({ children }: { children: React.ReactNode }) {
return <BasedProvider client={based}>{children}</BasedProvider>;
}Data — client.from(table)
The imperative counterpart to the hooks, for event handlers, server scripts, and
anything outside React. Every method throws a BasedError on failure.
import { createClient } from "@weirdscience/based-client";
import type { Tables } from "./based";
const based = createClient<Tables>({ url, anonKey });
const posts = based.from("posts");
// List rows: filter, order, limit (default 20, max 100), offset
const { data, total } = await posts.select({
filter: { status: "published" },
order: "createdAt.desc",
limit: 20,
offset: 0,
});
// One row by id — resolves to null on 404 instead of throwing
const post = await posts.get("V1StGXR8_Z5jdHi6B");
// Create. Omit id and the server generates a nanoid
const created = await posts.insert({ title: "Hello" });
// Upsert at an id you choose: creates if missing, updates if present
await based.from("preferences").upsert({ id: "alice--theme", value: "dark" });
// Same request, id passed separately
await posts.update("V1StGXR8_Z5jdHi6B", { title: "Renamed" });
// Delete
await posts.delete("V1StGXR8_Z5jdHi6B");select returns { data, total } where total counts the matching rows before
pagination. order is "<column>.asc" or "<column>.desc", checked against the
row type; omit it and the server sorts by id ascending so pagination is stable.
client.fetch
The request everything else is built on: it prefixes the project URL, attaches the anon key or the bearer token, and retries once after refreshing an expired access token. Use it for anything the builder does not cover.
import { createClient } from "@weirdscience/based-client";
const based = createClient({ url, anonKey });
const res = await based.fetch("/api/posts?limit=5&order=createdAt.desc");
const body = (await res.json()) as { data: unknown[]; total: number };Hooks
useUser()
Current authenticated user.
import { useUser } from "@weirdscience/based-client";
function Profile() {
const { user, isLoading } = useUser();
if (isLoading) return <p>...</p>;
if (!user) return <p>Not logged in</p>;
return <p>{user.email} ({user.role})</p>;
}user is { id, email, role }, where role is "owner" for the first account
created on the project and "member" for everyone after. Only an owner can
manage schema. error is part of the return shape for symmetry with the other
hooks, and is always null today — the hook reads client state, it never fetches.
useQuery(table, options?)
Read rows from a table. Returns { data, total, isLoading, error, refetch }.
import { useQuery } from "@weirdscience/based-client";
const { data, total, isLoading } = useQuery("posts", {
filter: { status: "published" },
order: "createdAt.desc",
limit: 20,
offset: 0,
});limit defaults to 20 and the server caps it at 100 — a larger value is clamped,
not rejected. order is "<column>.asc" or "<column>.desc", and the column is
checked against the table's type. Omit it and rows come back ordered by id
ascending, so pagination stays stable.
error is a HookError — { code, message, status?, details? }, the server's
error code and HTTP status, never an Error instance.
Gate the query behind auth or any boolean with enabled:
import { useQuery, useUser } from "@weirdscience/based-client";
function Notes() {
const { user } = useUser();
const { data, isLoading } = useQuery("notes", { enabled: !!user });
// Won't fire until `user` is truthy — avoids a 403 when signed out.
// While disabled: `data` is null and `isLoading` is false.
return <p>{isLoading ? "..." : `${data?.length ?? 0} notes`}</p>;
}Queries refetch on their own when the signed-in user changes, so signing out
clears the previous user's rows instead of leaving them on screen. Responses
that arrive out of order are discarded — only the newest request can write to
data.
useRecord(table, id, options?)
Fetch a single record by id via GET /api/:table/:id. Returns { data, isLoading, error, refetch } — data is a single row, not an array.
import { useRecord } from "@weirdscience/based-client";
function Post({ id }: { id: string }) {
const { data: post, isLoading } = useRecord("posts", id);
if (isLoading) return <Spinner />;
if (!post) return <NotFound />;
return <h1>{String(post.title)}</h1>;
}Pass null/undefined as the id to skip the query (same effect as enabled: false):
const { data } = useRecord("posts", selectedId); // no fetch until selectedId is setPerfect for deterministic keys like ${userId}:${key}:
const { data: prefs } = useRecord("preferences", `${user.id}:theme`);useMutation(table, operation)
Write rows. operation is "create" | "update" | "upsert" | "delete". Returns { mutate, isLoading, error }.
mutate resolves to the row on success and to null on failure — it never
throws, so onClick={() => mutate(...)} can't produce an unhandled rejection.
Check the returned value or error ({ code, message, status?, details? })
instead of reaching for try/catch.
| Operation | mutate(data) | Request |
| --- | --- | --- |
| create | { ...fields } | POST /api/:table — 201, unknown keys rejected |
| update | { id, ...fields } | PUT /api/:table/:id — upsert: 201 on create, 200 on update |
| upsert | { id, ...fields } | Same request as update; reads better when you pick the id |
| delete | { id } | DELETE /api/:table/:id |
import { useMutation } from "@weirdscience/based-client";
function NewPost() {
const { mutate, isLoading, error } = useMutation("posts", "create");
async function onCreate() {
const created = await mutate({ title: "Hello", content: "World" });
if (!created) return; // `error` holds { code, message, status?, details? }
}
return (
<>
<button onClick={onCreate} disabled={isLoading}>Create</button>
{error && <p>{error.message}</p>}
</>
);
}update, upsert, and delete all require an id field:
import { useMutation } from "@weirdscience/based-client";
function PostActions({ id }: { id: string }) {
const { mutate: update, error } = useMutation("posts", "update");
const { mutate: remove } = useMutation("posts", "delete");
return (
<>
<button onClick={() => update({ id, title: "Renamed" })}>Rename</button>
<button onClick={() => remove({ id })}>Delete</button>
{error && <p>{error.message}</p>}
</>
);
}Auth
import { useBasedClient } from "@weirdscience/based-client";
function LoginForm() {
const client = useBasedClient();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await client.auth.signIn(
fd.get("email") as string,
fd.get("password") as string
);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">Sign in</button>
</form>
);
}Methods on client.auth:
signUp(email, password)→ creates an account and signs insignIn(email, password)→ signs insignOut()→ deletes this account's sessions on the serverrefreshSession()→ manually refresh (happens automatically on 401)getUser()→ the current user from/auth/meonAuthStateChange(cb)→ callscb(state)on every auth change, returns an unsubscribe function
signUp and signIn throw a BasedError carrying the server's code, message, HTTP status, and optional details:
import { BasedError, createClient } from "@weirdscience/based-client";
const client = createClient({ url, anonKey });
async function signIn(email: string, password: string) {
try {
await client.auth.signIn(email, password);
} catch (err) {
if (err instanceof BasedError && err.status === 401) {
return "Wrong email or password";
}
throw err;
}
}Subscribe to every auth change — sign in, sign out, refresh, rehydration:
import { createClient } from "@weirdscience/based-client";
const client = createClient({ url, anonKey });
const unsubscribe = client.auth.onAuthStateChange((state) => {
console.log(state.user?.email ?? "signed out", state.isLoading);
});Access tokens auto-refresh on 401. Concurrent requests that all see a 401 share a single refresh — the server rotates the refresh token, so parallel refreshes would sign the user out.
Session persistence
Sessions persist across page reloads via localStorage by default, under the key
based.session. On mount, the client restores the saved session and validates it
by calling /auth/me.
Tokens in localStorage are readable by any script running on your origin, so an
XSS bug leaks the session. That is the deliberate tradeoff for a session that
survives a reload without a cookie backend — pass your own storage adapter (or
storage: false) if your threat model needs something stricter.
Use client.ready() or useUser().isLoading to avoid flashing a logged-out UI during hydration:
import { useUser } from "@weirdscience/based-client";
function Gate() {
const { user, isLoading } = useUser();
if (isLoading) return <Spinner />;
if (!user) return <LoginForm />;
return <Dashboard user={user} />;
}Opt out, pick your own key, or use a custom storage adapter:
import { createClient } from "@weirdscience/based-client";
// Disable entirely (in-memory only)
createClient({ url, anonKey, storage: false });
// Custom storage (cookies, IndexedDB, React Native AsyncStorage, etc.)
createClient({
url,
anonKey,
storageKey: "my-app.session",
storage: {
getItem: (k) => AsyncStorage.getItem(k),
setItem: (k, v) => AsyncStorage.setItem(k, v),
removeItem: (k) => AsyncStorage.removeItem(k),
},
});Storage adapters can return promises — the client awaits them.
Type safety
Generate types for your tables from the server:
based typegen
# writes based.d.tsImport it as ./based — never ./based.d.ts, which TypeScript refuses — and
pass Tables as a generic:
import type { Insert, Tables } from "./based";
import { useQuery, useMutation } from "@weirdscience/based-client";
// data is typed as Tables["posts"][] | null
const { data } = useQuery<Tables, "posts">("posts", {
filter: { status: "published" }, // typed keys
order: "createdAt.desc", // typed columns
});
// mutate() takes an Insert<"posts">: the row minus the columns the server
// fills in (id, createdAt, updatedAt)
const { mutate } = useMutation<Tables, "posts">("posts", "create");
const draft: Insert<"posts"> = {
title: "Hello",
content: "World",
status: "draft",
};
await mutate(draft);Re-run based typegen after any schema change.
Access policies
Each table has one policy, and based table access <table> <policy> changes it:
owner-scoped(default when the table has auser_idcolumn) — every signed-in user reads and writes only their own rows;user_idis forced to the caller on write; anon is rejected.public-read— anyone, anon included, reads every row; only the project owner writes.private(default without auser_idcolumn) — only the project owner reads or writes.
based table create notes user_id:text:required title:text:required body:text
based table access posts public-readWith owner-scoped:
useQuery("notes")only returns the caller's notesuseMutation("notes", "create")auto-fillsuser_id- other users' rows return 404
Links
License
MIT
