bknd logo

SDK (React)

Use the bknd SDK for React

There are several useful hooks to work with your backend:

  1. Simple hooks based on the API:
    • useApi - Access the API instance
    • useAuth - Authentication helpers and state
    • useEntity - CRUD operations without caching
  2. Query hooks that wrap the API in SWR:
  3. Utility hooks for advanced use cases:

Setup

In order to use the React hooks, make sure you wrap your <App /> inside <ClientProvider />. This provides the bknd API instance to all hooks in your component tree:

import { ClientProvider } from "bknd/client";

export default function App() {
  return <ClientProvider>{/* your app */}</ClientProvider>;
}

ClientProvider Props

The ClientProvider accepts the following props:

PropTypeDefault
baseUrl?
string
-
children?
ReactNode
-

All Api options are also supported and will be passed to the internal API instance. Common options include:

PropTypeDefault
token?
string
-
storage?
{ getItem, setItem, removeItem }
-
onAuthStateChange?
(state: AuthState) => void
-
fetcher?
(input: RequestInfo, init?: RequestInit) => Promise<Response>
-
credentials?
"include" | "omit" | "same-origin"
-

Usage Examples

Using with a remote bknd instance:

import { ClientProvider } from "bknd/client";

export default function App() {
  return (
    <ClientProvider baseUrl="https://your-bknd-instance.com">
      {/* your app */}
    </ClientProvider>
  );
}

Using with an embedded bknd instance (same origin):

import { ClientProvider } from "bknd/client";

export default function App() {
  // no baseUrl needed - will use window.location.origin
  return <ClientProvider>{/* your app */}</ClientProvider>;
}

Using with custom authentication:

import { ClientProvider } from "bknd/client";

export default function App() {
  return (
    <ClientProvider
      baseUrl="https://your-bknd-instance.com"
      token="your-auth-token"
      onAuthStateChange={(state) => {
        console.log("Auth state changed:", state);
      }}
    >
      {/* your app */}
    </ClientProvider>
  );
}

For all examples below, we'll assume that your app is wrapped inside the ClientProvider.

useApi()

Returns the Api instance from the ClientProvider context. This gives you direct access to all API methods for data, auth, media, and system operations.

import { useApi } from "bknd/client";

export default async function App() {
  const api = useApi();
  
  // access data API
  const posts = await api.data.readMany("posts");
  
  // access auth API
  const user = await api.auth.me();
  
  // access media API
  const files = await api.media.listFiles();
  
  // ...
}

Props:

PropTypeDefault
host?
string
-

See the SDK documentation for all available API methods and options.

useAuth()

Provides authentication state and helper functions for login, register, logout, and token management. This hook automatically tracks the authentication state from the ClientProvider context.

import { useAuth } from "bknd/client";

export default function AuthComponent() {
  const { user, verified, login, logout } = useAuth();

  if (!user) {
    return (
      <button
        onClick={async () => {
          await login({
            email: "user@example.com",
            password: "password123",
          });
        }}
      >
        Login
      </button>
    );
  }

  return (
    <div>
      <p>Welcome, {user.email}!</p>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Props

PropTypeDefault
baseUrl?
string
-

Return Values

PropTypeDefault
data?
Partial<AuthState>
-
user?
SafeUser | undefined
-
token?
string | undefined
-
verified?
boolean
-
login?
(data: { email: string; password: string }) => Promise<AuthResponse>
-
register?
(data: { email: string; password: string }) => Promise<AuthResponse>
-
logout?
() => Promise<void>
-
verify?
() => Promise<void>
-
setToken?
(token: string) => void
-

Usage Notes

  • The login and register functions automatically update the authentication state and store the token
  • The logout function clears the token and invalidates all SWR cache entries
  • The verify function checks if the current token is still valid with the server
  • Authentication state changes are automatically propagated to all components using useAuth

Authentication Patterns

Depending on your deployment architecture, there are different ways to handle authentication:

1. SPA with localStorage (Independent Deployments)

Use this pattern when your frontend and backend are deployed independently on different domains. The token is stored in the browser's localStorage.

import { ClientProvider, useAuth } from "bknd/client";
import { useEffect, useState } from "react";

// setup ClientProvider with localStorage
export default function App() {
  return (
    <ClientProvider
      baseUrl="https://your-backend.com"
      storage={window.localStorage}
    >
      <AuthComponent />
    </ClientProvider>
  );
}

function AuthComponent() {
  const auth = useAuth();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  // important: verify auth on mount to check if stored token is still valid
  useEffect(() => {
    auth.verify();
  }, []);

  if (auth.user) {
    return (
      <div>
        <p>Logged in as {auth.user.email}</p>
        <button onClick={() => auth.logout()}>Logout</button>
      </div>
    );
  }

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        await auth.login({ email, password });
      }}
    >
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Login</button>
    </form>
  );
}

2. SPA with Cookies (Same Domain)

Use this pattern when your frontend and backend are deployed on the same domain or when using a framework that serves both. Authentication is handled via HTTP-only cookies.

import { ClientProvider, useAuth } from "bknd/client";
import { useEffect } from "react";

// setup ClientProvider with credentials included
export default function App() {
  return (
    <ClientProvider
      baseUrl="https://your-app.com"
      credentials="include"
    >
      <InnerApp />
    </ClientProvider>
  );
}

function InnerApp() {
  const auth = useAuth();

  // important: verify auth on mount since cookies aren't readable from client-side JavaScript
  // cookies are included automatically in requests
  useEffect(() => {
    auth.verify();
  }, []);

  if (auth.user) {
    return (
      <div>
        <p>Logged in as {auth.user.email}</p>
        {/* logout by navigating to the logout endpoint */}
        <a href="/api/auth/logout">Logout</a>
      </div>
    );
  }

  // option 1: programmatic login
  return (
    <button
      onClick={async () => {
        await auth.login({ email: "user@example.com", password: "password" });
      }}
    >
      Login
    </button>
  );

  // option 2: form-based login (traditional)
  return (
    <form method="post" action="/api/auth/password/login">
      <input type="email" name="email" placeholder="Email" />
      <input type="password" name="password" placeholder="Password" />
      <button type="submit">Login</button>
    </form>
  );
}

Notes:

  • With credentials: "include", cookies are automatically sent with every request
  • The logout endpoint (/api/auth/logout) clears the cookie and redirects back to the referrer
  • You can use either programmatic login with auth.login() or traditional form submission

3. Full Stack (Embedded Mode)

Use this pattern when bknd is embedded in your framework (e.g., Next.js, Astro, React Router). The backend and frontend run in the same process.

// this example is not specific to any framework, but you can use it with any framework that supports server-side rendering

import { ClientProvider, useAuth } from "bknd/client";
import { useEffect } from "react";

// setup: extract user from server-side
// in your server-side code (e.g., Next.js loader, Astro endpoint):
export async function loader({ request }) {
  // create API instance from your app (may be available in context)
  const api = app.getApi({ request }); // extracts credentials from request
  // or: const api = app.getApi({ headers: request.headers });
  
  const user = api.getUser();
  // optionally: await api.verifyAuth();
  
  return { user };
}

// in your component:
export default function App({ user }) {
  return (
    <ClientProvider user={user}>
      <InnerApp />
    </ClientProvider>
  );
}

function InnerApp() {
  const auth = useAuth();

  // optionally verify if not already verified
  useEffect(() => {
    if (!auth.verified) {
      auth.verify();
    }
  }, []);

  if (auth.user) {
    return (
      <div>
        <p>Logged in as {auth.user.email}</p>
        {/* logout by navigating to the logout endpoint */}
        <a href="/api/auth/logout">Logout</a>
      </div>
    );
  }

  // use form-based authentication for full-stack apps
  return (
    <form method="post" action="/api/auth/password/login">
      <input type="email" name="email" placeholder="Email" />
      <input type="password" name="password" placeholder="Password" />
      <button type="submit">Login</button>
    </form>
  );
}

Notes:

  • No baseUrl needed in ClientProvider - it automatically uses the same origin
  • Pass the user prop from server-side to avoid an initial unauthenticated state
  • Use app.getApi({ request }) or app.getApi({ headers }) on the server to extract credentials
  • The logout endpoint (/api/auth/logout) clears the session and redirects back
  • Authentication persists via cookies automatically handled by the framework

useApiQuery()

This hook wraps the API class in an SWR hook for convenience. You can use any API endpoint supported, like so:

import { useApiQuery } from "bknd/client";

export default function App() {
  const { data, ...swr } = useApiQuery((api) => api.data.readMany("comments"));

  if (swr.error) return <div>Error</div>;
  if (swr.isLoading) return <div>Loading...</div>;

  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Props

PropTypeDefault
selector
(api: Api) => FetchPromise
-
options?
SWRConfiguration & { enabled?: boolean; refine?: (data: Data) => Data | any }
-

Options properties:

PropTypeDefault
enabled?
boolean
-
refine?
(data: Data) => Data | any
-

Using mutations

To query and mutate data using this hook, you can leverage the parameters returned. In the following example we'll also use a refine function as well as revalidateOnFocus (option from SWRConfiguration) so that our data keeps updating on window focus change.

import { useEffect, useState } from "react";
import { useApiQuery } from "bknd/client";

export default function App() {
  const [text, setText] = useState("");
  const { data, api, mutate, ...q } = useApiQuery(
    (api) => api.data.readOne("comments", 1),
    {
      // filter to a subset of the response
      refine: (data) => data.data,
      revalidateOnFocus: true,
    },
  );

  const comment = data ? data : null;

  useEffect(() => {
    setText(comment?.content ?? "");
  }, [comment]);

  if (q.error) return <div>Error</div>;
  if (q.isLoading) return <div>Loading...</div>;

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        if (!comment) return;

        // this will automatically revalidate the query
        await mutate(async () => {
          const res = await api.data.updateOne("comments", comment.id, {
            content: text,
          });
          return res.data;
        });

        return false;
      }}
    >
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button type="submit">Update</button>
    </form>
  );
}

useEntity()

This hook wraps the endpoints of DataApi and returns CRUD options as parameters:

import { useState, useEffect } from "react";
import { useEntity } from "bknd/client";

export default function App() {
  const [data, setData] = useState<any>();
  const { create, read, update, _delete } = useEntity("comments", 1);

  useEffect(() => {
    read().then(setData);
  }, []);

  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

If you only supply the entity name as string without an ID, the read method will fetch a list of entities instead of a single entry.

Props

PropTypeDefault
entity
string
-
id?
number | string
-

Returned actions

PropTypeDefault
create?
(input: object) => Promise<Response>
-
read?
(query?: RepoQueryIn) => Promise<Response>
-
update?
(input: object, id?: number | string) => Promise<Response>
-
_delete?
(id?: number | string) => Promise<Response>
-

useEntityQuery()

This hook wraps the actions from useEntity around SWR for automatic data fetching, caching, and revalidation. It combines the power of SWR with CRUD operations for your entities.

import { useEntityQuery } from "bknd/client";

export default function App() {
  const { data } = useEntityQuery("comments", 1);

  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Important: The returned CRUD actions are typed differently based on whether you provide an id:

  • With id (single item mode): update and _delete don't require an id parameter since the item is already specified
  • Without id (list mode): update and _delete require an id parameter to specify which item to modify
// single item mode - id is already specified
const { data, update, _delete } = useEntityQuery("comments", 1);
await update({ content: "new text" }); // no id needed
await _delete(); // no id needed

// list mode - must specify which item to update/delete
const { data, update, _delete } = useEntityQuery("comments");
await update({ content: "new text" }, 1); // id required
await _delete(1); // id required

Props

PropTypeDefault
entity
string
-
id?
number | string
-
query?
RepoQueryIn
-
options?
SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
-

Query Parameters

The query parameter accepts a RepoQueryIn object with the following options:

PropTypeDefault
limit?
number
10
offset?
number
0
sort?
string | string[]
id
where?
object
-
with?
string[]
-

Options

The options parameter extends SWR's configuration and adds bknd-specific options:

PropTypeDefault
enabled?
boolean
true
revalidateOnMutate?
boolean
true
keepPreviousData?
boolean
true
revalidateOnFocus?
boolean
false

All standard SWR configuration options are also supported.

Return Values

The hook returns an object with the following properties:

SWR Properties:

PropTypeDefault
data?
Entity | Entity[]
-
error?
Error
-
isLoading?
boolean
-
isValidating?
boolean
-

CRUD Actions (auto-wrapped with cache revalidation):

PropTypeDefault
create?
(input: object) => Promise<Response>
-
update?
(input: object, id?: number | string) => Promise<Response>
-
_delete?
(id?: number | string) => Promise<Response>
-

Cache Management:

PropTypeDefault
mutate?
(id?: number | string) => Promise<void>
-
mutateRaw?
SWRResponse["mutate"]
-
key?
string
-
api?
Api["data"]
-

Query Example

Fetching a limited, sorted list of entities:

import { useEntityQuery } from "bknd/client";

export default function TodoList() {
  const { data: todos, isLoading } = useEntityQuery("todos", undefined, {
    limit: 5,
    sort: "-id", // descending by id
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <ul>
      {todos?.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Using mutations

All actions returned from useEntityQuery are conveniently wrapped to automatically revalidate the cache after mutations:

import { useState, useEffect } from "react";
import { useEntityQuery } from "bknd/client";

export default function App() {
  const [text, setText] = useState("");
  const { data, update, ...q } = useEntityQuery("comments", 1);

  const comment = data ? data : null;

  useEffect(() => {
    setText(comment?.content ?? "");
  }, [comment]);

  if (q.error) return <div>Error</div>;
  if (q.isLoading) return <div>Loading...</div>;

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        if (!comment) return;

        // this will automatically revalidate the query
        await update({ content: text });

        return false;
      }}
    >
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button type="submit">Update</button>
    </form>
  );
}

Complete CRUD Example

Here's a comprehensive example showing all CRUD operations with query parameters:

import { useEntityQuery } from "bknd/client";

export default function TodoList() {
  const { data: todos, create, update, _delete, isLoading } = useEntityQuery(
    "todos",
    undefined, // no id, so we fetch a list
    {
      limit: 10,
      sort: "-id", // newest first
    }
  );

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <form
        action={async (formData: FormData) => {
          const title = formData.get("title") as string;
          await create({ title, done: false });
        }}
      >
        <input type="text" name="title" placeholder="New todo" />
        <button type="submit">Add</button>
      </form>

      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={!!todo.done}
              onChange={async () => {
                await update({ done: !todo.done }, todo.id);
              }}
            />
            <span>{todo.title}</span>
            <button onClick={() => _delete(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Mutation Behavior

Important notes about mutations:

  • Auto-revalidation: By default, all mutations (create, update, _delete) automatically revalidate all queries for that entity. This ensures your UI stays in sync.

  • Optimistic updates: For more advanced scenarios, you can use mutateRaw to implement optimistic updates manually.

  • Disable auto-revalidation: If you need more control, set revalidateOnMutate: false:

const { data, update } = useEntityQuery("comments", 1, undefined, {
  revalidateOnMutate: false,
});

// now update won't trigger automatic revalidation
await update({ content: "new text" });
  • Manual revalidation: Use the returned mutate function to manually trigger revalidation:
const { mutate } = useEntityQuery("comments");

// revalidate all "comments" queries
await mutate();

// revalidate specific comment
await mutate(commentId);

Utility Hooks

useInvalidate()

This hook provides a convenient way to invalidate SWR cache entries for manual revalidation.

import { useInvalidate } from "bknd/client";

export default function App() {
  const invalidate = useInvalidate();

  const handleRefresh = async () => {
    // invalidate by string key prefix
    await invalidate("/data/comments");

    // or invalidate using API selector
    await invalidate((api) => api.data.readMany("comments"));
  };

  return <button onClick={handleRefresh}>Refresh Comments</button>;
}

Options:

PropTypeDefault
options?
{ exact?: boolean }
-

Options properties:

PropTypeDefault
exact?
boolean
-

useEntityMutate()

This hook provides mutation actions without fetching data. Useful when you only need to perform CRUD operations without subscribing to data updates.

import { useEntityMutate } from "bknd/client";

export default function QuickActions() {
  const { create, update, _delete, mutate } = useEntityMutate("todos");

  const createTodo = async () => {
    await create({ title: "New todo", done: false });
    // manually update cache
    await mutate();
  };

  return <button onClick={createTodo}>Quick Add Todo</button>;
}

Props:

PropTypeDefault
entity
string
-
id?
number | string
-
options?
SWRConfiguration
-

Return Values:

PropTypeDefault
create?
(input: object) => Promise<Response>
-
update?
(input: object, id?: number | string) => Promise<Response>
-
_delete?
(id?: number | string) => Promise<Response>
-
mutate?
(id: number | string, data: Partial<Entity>) => Promise<void>
-