Skip to main content
The example app is the fastest way to see the flow and stays in sync with the SDK. Follow these steps to build the same integration directly into your own app — the routes and hook below are the ones the example uses.

Install

The snippets below use Next.js App Router, but @opendatalabs/vana-sdk isn’t tied to Next — the server controller runs in any Node backend and the hook works in any React app. Use an existing Next.js project (or adapt the routes to your framework). To start fresh:
npx create-next-app@latest vana-direct-demo --ts --app --eslint --src-dir --import-alias "@/*"
cd vana-direct-demo
Install the Vana SDK:
npm install @opendatalabs/vana-sdk viem

Configure the backend

Set server-side environment variables in .env.local:
.env.local
VANA_APP_PRIVATE_KEY=0x...
VANA_APP_URL=http://localhost:3000
VANA_ENV=production
VANA_NETWORK=moksha
Use VANA_ENV=production for the current Direct app flow. Use VANA_NETWORK=moksha (testnet) — mainnet deployment is being finalized, so build on testnet for now. Create lib/vana.ts:
lib/vana.ts
import { createDirectDataController } from "@opendatalabs/vana-sdk/server";

const network = process.env.VANA_NETWORK === "mainnet" ? "mainnet" : "moksha";

export const vana = createDirectDataController({
  env: "production",
  network,
  appPrivateKey: process.env.VANA_APP_PRIVATE_KEY!,
  app: {
    id: "spotify-taste",
    name: "Spotify Taste",
    homepageUrl: process.env.VANA_APP_URL!,
  },
  source: "spotify",
  scopes: ["spotify.profile"],
});

export const appAddress = vana.getAppAddress();
The SDK resolves the escrow contract and escrow gateway from the selected network — you don’t pass an address. Just fund escrow for your app on that network. (Pass an escrow config only to override the defaults for a custom deployment.) Replace source and scopes with values from the selected source detail or connector schema.

Create API routes

Create an access request route:
app/api/vana/request/route.ts
import { vana } from "@/lib/vana";

export async function POST() {
  const request = await vana.createAccessRequest({
    returnUrl: `${process.env.VANA_APP_URL}/connect/return`,
  });

  return Response.json(request);
  // {
  //   requestId: "dcr_123",
  //   approvalUrl: "https://app.vana.org/...",
  //   appAddress: "0x1234..."
  // }
}
Create a return page:
app/connect/return/page.tsx
export default function ConnectReturnPage() {
  return (
    <main>
      <h1>Approval complete</h1>
      <p>You can close this tab and return to the app.</p>
    </main>
  );
}
Vana redirects the approval tab to this page after approval. The original app tab continues polling status and reads the approved data. Create a status route:
app/api/vana/status/route.ts
import { vana } from "@/lib/vana";

export async function GET(request: Request) {
  const requestId = new URL(request.url).searchParams.get("requestId");

  if (!requestId) {
    return Response.json({ error: "Missing requestId" }, { status: 400 });
  }

  const status = await vana.getAccessRequestStatus(requestId);
  return Response.json(status);
  // Approved:
  // {
  //   status: "approved",
  //   personalServerUrl: "https://...",
  //   grantId: "0xabc...",
  //   scope: "spotify.profile"
  // }
}
Create a read route:
app/api/vana/data/route.ts
import { vana } from "@/lib/vana";

export async function GET(request: Request) {
  const requestId = new URL(request.url).searchParams.get("requestId");

  if (!requestId) {
    return Response.json({ error: "Missing requestId" }, { status: 400 });
  }

  const result = await vana.readApprovedData({ requestId });
  return Response.json(result);
}
readApprovedData reads from the user’s Personal Server. If payment is required, the SDK signs the protocol challenge with your app key, pays from your app’s escrow using the network’s escrow contract and gateway, retries with X-PAYMENT, and returns the paid read result. If your app’s escrow balance is unfunded, the read fails with Insufficient finalized balancefund escrow and retry.

Add React

The frontend calls your backend, opens Vana approval, polls status, asks your backend to read approved data, and renders the returned result.
app/components/ConnectSpotifyButton.tsx
"use client";

import { useDirectVanaConnect } from "@opendatalabs/vana-sdk/react";

async function jsonFetch(path: string, init?: RequestInit) {
  const res = await fetch(path, init);
  if (!res.ok) throw new Error(`${res.status} from ${path}`);
  return res.json();
}

export function ConnectSpotifyButton() {
  const connect = useDirectVanaConnect({
    createRequest: () => jsonFetch("/api/vana/request", { method: "POST" }),
    getStatus: (requestId: string) =>
      jsonFetch(`/api/vana/status?requestId=${encodeURIComponent(requestId)}`),
    readResult: (requestId: string) =>
      jsonFetch(`/api/vana/data?requestId=${encodeURIComponent(requestId)}`),
  });

  return (
    <div>
      <button
        disabled={connect.state.type !== "idle"}
        onClick={() => connect.start()}
        type="button"
      >
        {connect.state.type === "idle" ? "Connect Spotify" : "Connecting..."}
      </button>

      {connect.state.type === "done" ? (
        <pre>{JSON.stringify(connect.state.result, null, 2)}</pre>
      ) : null}

      {connect.state.type === "error" ? (
        <p role="alert">{connect.state.error.message}</p>
      ) : null}
    </div>
  );
}
Render <ConnectSpotifyButton /> from a page in your app.