Version: 1.0 · Last Updated: 2026-02-08
Complete guide for ObjectStack SDK integration, API usage patterns, and authentication.
- SDK Overview
- Client Initialization
- Authentication
- Provider Setup
- Metadata API
- Data API
- Package API
- Storage API
- Analytics API
- Automation API
- React Hook Usage
- Error Handling
- SDK Gap Workarounds
| Package | Version | Purpose |
|---|---|---|
@objectstack/client |
^1.1.0 |
Core HTTP client |
@objectstack/client-react |
^1.1.0 |
React hooks and provider |
better-auth |
^1.4.18 |
Authentication framework |
@better-auth/expo |
^1.4.18 |
Expo-specific auth adapter |
ObjectStackClient
├── .meta.* ← Metadata (objects, fields, views)
├── .data.* ← CRUD operations (query, create, update, delete)
├── .packages.* ← App/package management
├── .storage.* ← File upload/download
├── .analytics.* ← Analytics queries
├── .automation.* ← Workflow triggers
├── .auth.* ← Authentication
└── .hub.* ← Hub/space management
// lib/objectstack.ts
import { ObjectStackClient } from "@objectstack/client";
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3000";
export function createObjectStackClient(token?: string): ObjectStackClient {
return new ObjectStackClient({
baseUrl: API_URL,
token,
});
}
// Singleton for unauthenticated requests
export const objectStackClient = new ObjectStackClient({
baseUrl: API_URL,
});| Environment Variable | Default | Description |
|---|---|---|
EXPO_PUBLIC_API_URL |
http://localhost:3000 |
ObjectStack server URL |
EXPO_PUBLIC_APP_SCHEME |
objectstack |
Deep link URL scheme |
The client is recreated with the auth token whenever the session changes:
// app/_layout.tsx
const { data: session } = authClient.useSession();
const token = (session as any)?.token ?? (session as any)?.accessToken;
const client = useMemo(() => createObjectStackClient(token), [token]);// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:8081",
plugins: [
expoClient({
scheme: "objectstack", // Deep link scheme for OAuth callbacks
storage: SecureStore, // Encrypted token storage
}),
],
});1. User opens app → useProtectedRoute() checks session
2. No session → redirect to (auth)/sign-in
3. User enters credentials → authClient.signIn.email()
4. Token stored in expo-secure-store (encrypted)
5. Client recreated with token → ObjectStackProvider updated
6. User redirected to (tabs)/ main app
// app/_layout.tsx
function useProtectedRoute() {
const { data: session, isPending } = authClient.useSession();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isPending) return;
const inAuthGroup = segments[0] === "(auth)";
if (!session && !inAuthGroup) {
router.replace("/(auth)/sign-in");
} else if (session && inAuthGroup) {
router.replace("/(tabs)");
}
}, [session, isPending, segments]);
}| Operation | Method |
|---|---|
| Sign in (email) | authClient.signIn.email({ email, password }) |
| Sign up | authClient.signUp.email({ email, password, name }) |
| Sign out | authClient.signOut() |
| Get session | authClient.useSession() |
| Social login | authClient.signIn.social({ provider }) |
The root layout wraps the app with necessary providers:
// app/_layout.tsx
export default function RootLayout() {
useProtectedRoute();
const { data: session } = authClient.useSession();
const token = (session as any)?.token ?? (session as any)?.accessToken;
const client = useMemo(() => createObjectStackClient(token), [token]);
return (
<ObjectStackProvider client={client}> {/* SDK context */}
<QueryClientProvider client={queryClient}> {/* TanStack Query */}
<SafeAreaProvider> {/* Safe areas */}
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(app)" />
</Stack>
</SafeAreaProvider>
</QueryClientProvider>
</ObjectStackProvider>
);
}ObjectStackProvider— SDK client contextQueryClientProvider— TanStack Query cacheSafeAreaProvider— Safe area insetsStack— Expo Router navigation
// Get object definition (fields, relationships)
const { data: object } = useObject("contacts");
// object.fields: FieldDefinition[]
// object.name, object.label, etc.// Get view for an object
const { data: view } = useView("contacts", "list");
// view.viewType: "list" | "form" | "detail" | "dashboard" | ...
// view.listView: { columns, filter, sort, pagination }
// view.formView: { sections, type }
// view.actions: ActionMeta[]// Get fields for an object
const { data: fields } = useFields("contacts");
// fields: FieldDefinition[]
// Each field: { name, label, type, required, options, ... }// Fetch any metadata type
const { data } = useMetadata("custom_type", "custom_name");const client = useClient();
// List metadata types
const types = await client.meta.getTypes();
// Get items of a type
const items = await client.meta.getItems("object", { cached: true });
// Get single item
const item = await client.meta.getItem("object", "contacts");
// Save metadata
await client.meta.saveItem("view", "contacts_list", viewData);
// ETag-cached fetch
const cached = await client.meta.getCached("contacts", { type: "object" });// Simple query with SDK hook
const { data, isLoading, error, refetch } = useQuery("contacts", {
filter: ["status", "eq", "active"],
sort: ["-created_at"],
limit: 20,
offset: 0,
fields: ["id", "name", "email", "status"],
});// Paginated query
const { data, page, nextPage, prevPage, isLoading } = usePagination("contacts", {
pageSize: 20,
filter: ["status", "eq", "active"],
});// Infinite scroll query
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery("contacts", {
pageSize: 20,
});// CRUD mutations
const { create, update, remove } = useMutation("contacts");
// Create
await create({ name: "John Doe", email: "john@example.com" });
// Update
await update("rec_abc123", { status: "active" });
// Delete
await remove("rec_abc123");const client = useClient();
// Full QueryAST query
const results = await client.data.query("contacts", {
filter: ["AND", ["status", "eq", "active"], ["amount", "gte", 1000]],
sort: ["-created_at"],
limit: 50,
offset: 0,
fields: ["id", "name", "status", "amount"],
});
// Simplified find
const records = await client.data.find("contacts", {
filter: { status: "active" },
limit: 10,
});
// Get single record
const record = await client.data.get("contacts", "rec_abc123");
// Create
const created = await client.data.create("contacts", {
name: "Jane Doe",
email: "jane@example.com",
});
// Batch create
const batchResult = await client.data.createMany("contacts", [
{ name: "User 1" },
{ name: "User 2" },
]);
// Update
const updated = await client.data.update("contacts", "rec_abc123", {
status: "inactive",
});
// Batch update
await client.data.batch("contacts", {
updates: [
{ id: "rec_1", data: { status: "active" } },
{ id: "rec_2", data: { status: "active" } },
],
});
// Simplified batch update
await client.data.updateMany("contacts", [
{ id: "rec_1", status: "active" },
{ id: "rec_2", status: "active" },
]);
// Delete
await client.data.delete("contacts", "rec_abc123");
// Batch delete
await client.data.deleteMany("contacts", ["rec_1", "rec_2", "rec_3"]);const client = useClient();
// List installed packages (apps)
const { packages } = await client.packages.list({ enabled: true });
// Get specific package
const pkg = await client.packages.get("pkg_abc123");
// Install package
await client.packages.install(manifest);
// Uninstall package
await client.packages.uninstall("pkg_abc123");import { useAppDiscovery } from "~/hooks/useAppDiscovery";
const { apps, isLoading, error, refetch } = useAppDiscovery();
// apps: AppManifest[] — id, name, label, description, icon, version, enabledconst client = useClient();
// Upload file
const result = await client.storage.upload(file);
// result: { id, url, filename, size, mimeType }
// Get download URL
const url = await client.storage.getDownloadUrl(fileId);import { useFileUpload } from "~/hooks/useFileUpload";
const { pickImage, pickDocument, upload, isUploading, progress } = useFileUpload();
// Pick image from gallery
const image = await pickImage();
// Pick document
const doc = await pickDocument();
// Upload to server
const result = await upload(image);const client = useClient();
// Run analytics query
const data = await client.analytics.query({
object: "orders",
measures: [{ field: "amount", aggregate: "sum" }],
dimensions: ["status"],
filter: ["created_at", "gte", "2026-01-01"],
});
// Get analytics metadata
const meta = await client.analytics.meta("orders");
// Explain query plan
const explanation = await client.analytics.explain(queryPlan);import { useAnalyticsQuery } from "~/hooks/useAnalyticsQuery";
import { useAnalyticsMeta } from "~/hooks/useAnalyticsMeta";
const { data, isLoading, error } = useAnalyticsQuery({
object: "orders",
measures: [{ field: "amount", aggregate: "sum" }],
dimensions: ["status"],
});
const { meta, isLoading: metaLoading } = useAnalyticsMeta("orders");const client = useClient();
// Trigger an automation flow
await client.automation.trigger("flow_name", {
recordId: "rec_abc123",
action: "approve",
params: { comment: "Approved by mobile" },
});All SDK hooks are re-exported via hooks/useObjectStack.ts for convenience:
// Import from local hooks (preferred)
import { useClient, useQuery, useMutation } from "~/hooks/useObjectStack";
// Available hooks:
// useClient, useQuery, useMutation, usePagination,
// useInfiniteQuery, useObject, useView, useFields, useMetadata// Typical list view data loading
function ContactList() {
const { data: fields } = useFields("contacts");
const { data: view } = useView("contacts", "list");
const { data: records, isLoading, fetchNextPage } = useInfiniteQuery("contacts", {
pageSize: view?.listView?.pagination?.pageSize ?? 20,
sort: view?.listView?.sort,
});
if (isLoading) return <LoadingScreen />;
return (
<ViewRenderer
viewType="list"
props={{ records, fields, view, onLoadMore: fetchNextPage }}
/>
);
}// lib/error-handling.ts
type ObjectStackErrorCode =
| "UNAUTHORIZED" // 401 — Session expired
| "FORBIDDEN" // 403 — No permission
| "NOT_FOUND" // 404 — Resource not found
| "VALIDATION_ERROR" // 422 — Invalid input
| "CONFLICT" // 409 — Data conflict
| "RATE_LIMITED" // 429 — Too many requests
| "INTERNAL_ERROR" // 500 — Server error
| "NETWORK_ERROR" // No connectivity
| "TIMEOUT"; // Request timeoutimport { parseError, getUserErrorMessage } from "~/lib/error-handling";
try {
await client.data.create("contacts", data);
} catch (err) {
const parsed = parseError(err);
// parsed: { code, message, details? }
// User-friendly message
const message = getUserErrorMessage(err);
// → "Please check your input and try again."
}| Code | Message |
|---|---|
UNAUTHORIZED |
Your session has expired. Please sign in again. |
FORBIDDEN |
You don't have permission to perform this action. |
NOT_FOUND |
The requested resource was not found. |
VALIDATION_ERROR |
Please check your input and try again. |
CONFLICT |
A conflict occurred. The data may have been modified by someone else. |
RATE_LIMITED |
Too many requests. Please wait a moment and try again. |
INTERNAL_ERROR |
Something went wrong on the server. Please try again later. |
NETWORK_ERROR |
Unable to connect to the server. Please check your internet connection. |
TIMEOUT |
The request timed out. Please try again. |
The client.views.* API exists at runtime but lacks TypeScript types:
// hooks/useViewStorage.ts
function viewsApi() {
return (client as any).views;
}
// Usage:
const views = await viewsApi().list(objectName);
await viewsApi().save(objectName, viewName, viewData);
await viewsApi().delete(objectName, viewName);Status: Workaround using (client as any).views. Will be replaced when SDK provides typed API (see ROADMAP.md).
The following APIs are not yet available in the SDK and are tracked in ROADMAP.md:
| Missing API | Blocker For | Workaround |
|---|---|---|
client.permissions.* |
Permission-based UI | None (feature blocked) |
client.workflows.* |
Workflow/approval UI | None (feature blocked) |
client.realtime.* |
Real-time updates | Polling via refetch |
client.notifications.* |
Push notifications | None (feature blocked) |
client.ai.* |
AI/NLQ features | None (feature blocked) |
client.i18n.* |
Server translations | Static client-side i18n |
This document covers API integration as of @objectstack/client@1.1.0. See ROADMAP.md for gap analysis and DATA-LAYER.md for offline data architecture.