SDK
The React SDK gives you a typed client, auth helpers, and hooks to connect your frontend to Based.
Install
bun add @weirdscience/based-client
# or: npm install @weirdscience/based-client
# or: pnpm add @weirdscience/based-clientCreate a client
import { createClient } from "@weirdscience/based-client";
export const based = createClient({
url: process.env.NEXT_PUBLIC_BASED_URL!,
anonKey: process.env.NEXT_PUBLIC_BASED_ANON_KEY!,
});Wrap your app
BasedProvider makes the client available to all hooks in the component tree. The package ships the "use client" directive in both its ESM and CJS bundles, so you can render the provider straight from app/layout.tsx without a wrapper component. The hooks themselves still have to be called from Client Components.
import { BasedProvider } from "@weirdscience/based-client";
import { based } from "@/lib/based";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<BasedProvider client={based}>
{children}
</BasedProvider>
);
}Type Safety
The SDK supports end-to-end type safety through generated TypeScript types. Run based typegen to generate a based.d.ts file describing every one of your tables.
based typegenThis produces a file like the following. Table keys are the raw SQLite names, column keys are camelCased, id is a string, and timestamps come back as string:
export type Tables = {
posts: {
id: string;
title: string;
content: string | null;
status: string | null;
createdAt: string;
updatedAt: string;
};
};
export type TableName = keyof Tables;
export type Row<T extends TableName> = Tables[T];
export type Insert<T extends TableName> =
Omit<Tables[T], "id" | "createdAt" | "updatedAt"> & { id?: string };Import it as ./based — never ./based.d.ts, which TypeScript refuses. Pass the Tables type and a table name as generics to useQuery and useMutation for full inference on filters, rows, and mutation payloads.
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" },
order: "createdAt.desc",
});
// mutate() takes an Insert<"posts"> — the row without the columns
// the server fills in (id, createdAt, updatedAt)
const { mutate } = useMutation<Tables, "posts">("posts", "create");
const draft: Insert<"posts"> = {
title: "Hello world",
content: "My first post",
status: "draft",
};
const created = await mutate(draft);Re-run based typegen after any schema change to keep your types in sync.
Data — client.from(table)
The data builder is the imperative counterpart to the React hooks. Every method throws a BasedError on failure, with the server's code, HTTP status, and optional details.
import { based } from "@/lib/based";
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,
});
// Fetch one row (returns null on 404 instead of throwing)
const post = await posts.get("abc");
// Insert — the server generates the id when you omit it
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" });
// Update by id (PUT, so it also creates the row when it is missing)
await posts.update("abc", { title: "Renamed" });
// Delete
await posts.delete("abc");Type createClient with createClient<Tables> and the table name, filters, order, and row shapes all autocomplete and type-check.
Without React
Everything above except the hooks is framework-agnostic. Import it from @weirdscience/based-client/core: no React dependency, no "use client" directive, same createClient, from(), auth and BasedError. Use it from Vue, Svelte, Node, Bun, Deno, a worker, or a script.
import { createClient } 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("you@example.com", "password123");
const { data } = await based.from("posts").select({ order: "createdAt.desc" });import { createClient } from "@weirdscience/based-client";
import type { Tables } from "./based";
export const based = createClient<Tables>({
url: process.env.NEXT_PUBLIC_BASED_URL!,
anonKey: process.env.NEXT_PUBLIC_BASED_ANON_KEY!,
});Escape hatch — client.fetch
client.fetch(path, init) is the raw request the rest of the SDK is built on: it prefixes your project URL, attaches the anon key or the bearer token, and retries once after refreshing an expired access token. Use it for endpoints the builder does not cover.
const res = await based.fetch("/api/posts?limit=5&order=createdAt.desc");
const body = (await res.json()) as { data: unknown[]; total: number };Auth methods
The client exposes auth methods directly on client.auth. Both signUp and signIn take positional arguments and throw a BasedError when the server rejects them.
// Sign up a new user
await based.auth.signUp("user@example.com", "password123");
// Sign in
await based.auth.signIn("user@example.com", "password123");
// Sign out (deletes this account's sessions on the server)
await based.auth.signOut();
// Fetch the current user via /auth/me
const me = await based.auth.getUser();
// Manually refresh the session
await based.auth.refreshSession();
// React to every auth change; returns an unsubscribe function
const unsubscribe = based.auth.onAuthStateChange((state) => {
console.log(state.user?.email ?? "signed out");
});The SDK automatically refreshes tokens when a request returns a 401. You do not need to call refreshSession manually in most cases. Concurrent 401s share a single refresh, because the server rotates the refresh token and parallel refreshes would sign the user out.
Session persistence
Sessions persist across page reloads via localStorage by default, under the key based.session (change it with storageKey). On mount, the client restores the saved tokens and validates them by calling /auth/me.
Tokens in localStorage are readable by any script running on your origin, so an XSS bug leaks a session. That is the deliberate tradeoff for a session that survives a reload without a cookie backend — pass a custom storage adapter if your threat model needs something stricter.
Use useUser().isLoading or client.ready() to avoid flashing a logged-out UI during hydration:
import { useUser } from "@weirdscience/based-client";
export function Gate() {
const { user, isLoading } = useUser();
if (isLoading) return <Spinner />;
if (!user) return <LoginForm />;
return <Dashboard user={user} />;
}Opt out, or use a custom storage adapter (cookies, IndexedDB, React Native AsyncStorage, etc.):
// Disable persistence entirely (in-memory only)
createClient({ url, anonKey, storage: false });
// Custom storage adapter — getItem/setItem/removeItem can return promises
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),
},
});