> ## Documentation Index
> Fetch the complete documentation index at: https://docs.profy.cn/llms.txt
> Use this file to discover all available pages before exploring further.

# Next.js Full-Stack Integration

> Build a complete Next.js 14+ full-stack application using the Profy App SDK, covering OAuth login, Token persistence, and billing event reporting

## What You'll Build

This tutorial walks you through building a Next.js 14+ (App Router) full-stack application from scratch, implementing Profy OAuth login, secure Token storage, and per-use billing event reporting. After users click "Login with Profy" and complete authorization, the application automatically manages the Token lifecycle and reports billing events to Profy when users trigger paid actions.

```mermaid theme={null}
sequenceDiagram
    participant User as User Browser
    participant App as Next.js App
    participant Profy as Profy OAuth

    User->>App: Click "Login with Profy"
    App->>Profy: Redirect to /oauth/authorize
    Profy->>User: Display authorization page
    User->>Profy: Confirm authorization
    Profy->>App: Callback ?code=xxx
    App->>Profy: exchangeCode(code)
    Profy->>App: { accessToken, refreshToken }
    App->>App: Store Token in DB
    User->>App: Trigger paid action
    App->>Profy: reportEvent(eventName, token)
    Profy->>App: { charged, balance_remaining }
```

## Prerequisites

<CardGroup cols={2}>
  <Card title="Profy Developer Account" icon="user">
    Registered a Profy account and created an App in the developer dashboard to obtain your Client ID and Client Secret
  </Card>

  <Card title="Development Environment" icon="code">
    Node.js 18+, npm/pnpm/bun, basic Next.js and React experience
  </Card>
</CardGroup>

## Step 1: Create a Next.js Project and Install Dependencies

<CodeGroup>
  ```bash npm theme={null}
  npx create-next-app@latest profy-demo --typescript --app --tailwind
  cd profy-demo
  npm install @profy-ai/sdk
  ```

  ```bash pnpm theme={null}
  pnpm create next-app profy-demo --typescript --app --tailwind
  cd profy-demo
  pnpm add @profy-ai/sdk
  ```

  ```bash bun theme={null}
  bun create next-app profy-demo --typescript --app --tailwind
  cd profy-demo
  bun add @profy-ai/sdk
  ```
</CodeGroup>

If you need Drizzle for Token persistence, also install:

```bash theme={null}
npm install drizzle-orm postgres
npm install -D drizzle-kit
```

## Step 2: Configure Environment Variables

```env .env.local theme={null}
PROFY_APP_ID=your_app_client_id
PROFY_APP_SECRET=your_app_client_secret
PROFY_CALLBACK_URL=http://localhost:3000/api/callback

# Drizzle Token Store (optional)
DATABASE_URL=postgres://user:pass@localhost:5432/profy_demo
```

<Warning>
  Never commit `.env.local` to version control. Make sure `.gitignore` includes this file.
</Warning>

## Step 3: Initialize the SDK

<CodeGroup>
  ```typescript lib/profy.ts theme={null}
  import { ProfyApp } from "@profy-ai/sdk";

  export const profy = new ProfyApp({
    clientId: process.env.PROFY_APP_ID!,
    clientSecret: process.env.PROFY_APP_SECRET!,
  });
  ```

  ```python config.py theme={null}
  import os
  from profy_sdk import ProfyApp

  profy = ProfyApp(
      client_id=os.environ["PROFY_APP_ID"],
      client_secret=os.environ["PROFY_APP_SECRET"],
  )
  ```
</CodeGroup>

<Note>
  The `ProfyApp` instance should be used as a singleton on the server side. Never expose `clientSecret` to the client.
</Note>

## Step 4: Create the OAuth Login Page

```tsx app/page.tsx theme={null}
const PROFY_AUTHORIZE_URL = "https://app.profy.cn/oauth/authorize";

export default function Home() {
  const clientId = process.env.PROFY_APP_ID!;
  const redirectUri = process.env.PROFY_CALLBACK_URL!;

  const authUrl = `${PROFY_AUTHORIZE_URL}?${new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: "events:write",
    response_type: "code",
  })}`;

  return (
    <main className="flex min-h-screen items-center justify-center">
      <a
        href={authUrl}
        className="rounded-lg bg-indigo-600 px-6 py-3 text-white font-medium hover:bg-indigo-700 transition-colors"
      >
        Login with Profy
      </a>
    </main>
  );
}
```

## Step 5: Handle the OAuth Callback

<CodeGroup>
  ```typescript app/api/callback/route.ts theme={null}
  import { NextRequest, NextResponse } from "next/server";
  import { profy } from "@/lib/profy";
  import { cookies } from "next/headers";

  export async function GET(request: NextRequest) {
    const code = request.nextUrl.searchParams.get("code");

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

    const redirectUri = process.env.PROFY_CALLBACK_URL!;
    const tokenResult = await profy.exchangeCode(code, redirectUri);

    const cookieStore = await cookies();
    cookieStore.set("profy_user_id", tokenResult.userId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 90,
    });

    // Token persistence — see Step 6
    await saveToken(tokenResult);

    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
  ```

  ```python main.py (FastAPI) theme={null}
  from fastapi import FastAPI, Request
  from fastapi.responses import RedirectResponse
  from config import profy

  app = FastAPI()

  @app.get("/api/callback")
  async def oauth_callback(request: Request, code: str | None = None):
      if not code:
          return {"error": "Missing code"}, 400

      redirect_uri = os.environ["PROFY_CALLBACK_URL"]
      token_result = await profy.exchange_code(code, redirect_uri)

      # Token persistence — see Step 6
      await save_token(token_result)

      response = RedirectResponse(url="/dashboard")
      response.set_cookie(
          "profy_user_id",
          token_result["user_id"],
          httponly=True,
          secure=os.environ.get("ENV") == "production",
          samesite="lax",
          max_age=60 * 60 * 24 * 90,
      )
      return response
  ```
</CodeGroup>

<Tip>
  The `expiresAt` returned by `exchangeCode` is the access token's expiration timestamp, used to determine when to refresh.
</Tip>

## Step 6: Token Persistence

### Option A: Using DrizzleTokenStore (Recommended)

The SDK provides out-of-the-box Drizzle integration:

<CodeGroup>
  ```typescript lib/token-store.ts theme={null}
  import { DrizzleTokenStore, profyOAuthToken } from "@profy-ai/sdk/contrib/drizzle";
  import { drizzle } from "drizzle-orm/postgres-js";
  import postgres from "postgres";

  const client = postgres(process.env.DATABASE_URL!);
  const db = drizzle(client);

  export const tokenStore = new DrizzleTokenStore(db);
  ```

  ```python token_store.py (SQLAlchemy) theme={null}
  from profy_sdk.contrib.sqlalchemy import SQLAlchemyTokenStore
  from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

  engine = create_async_engine(os.environ["DATABASE_URL"])
  async_session = async_sessionmaker(engine)

  token_store = SQLAlchemyTokenStore(async_session)
  ```
</CodeGroup>

Export the Token table in your schema:

```typescript db/schema.ts theme={null}
export { profyOAuthToken } from "@profy-ai/sdk/contrib/drizzle";
```

After running migrations to create the table, implement `saveToken` in the callback:

<CodeGroup>
  ```typescript lib/token-store.ts theme={null}
  import { isTokenExpired } from "@profy-ai/sdk";
  import { profy } from "./profy";

  export async function saveToken(tokenResult: {
    accessToken: string;
    refreshToken: string;
    userId: string;
    expiresAt: number;
  }) {
    await tokenStore.save(tokenResult.userId, {
      accessToken: tokenResult.accessToken,
      refreshToken: tokenResult.refreshToken,
      expiresAt: tokenResult.expiresAt,
    });
  }

  export async function getValidToken(userId: string) {
    const stored = await tokenStore.get(userId);
    if (!stored) throw new Error("User not authenticated");

    if (isTokenExpired(stored)) {
      const refreshed = await profy.refreshToken(stored.refreshToken);
      await tokenStore.save(userId, refreshed);
      return refreshed.accessToken;
    }

    return stored.accessToken;
  }
  ```

  ```python token_store.py theme={null}
  from profy_sdk import is_token_expired
  from config import profy

  async def save_token(token_result: dict):
      await token_store.save(
          token_result["user_id"],
          {
              "access_token": token_result["access_token"],
              "refresh_token": token_result["refresh_token"],
              "expires_at": token_result["expires_at"],
          },
      )

  async def get_valid_token(user_id: str) -> str:
      stored = await token_store.get(user_id)
      if not stored:
          raise Exception("User not authenticated")

      if is_token_expired(stored):
          refreshed = await profy.refresh_token(stored["refresh_token"])
          await token_store.save(user_id, refreshed)
          return refreshed["access_token"]

      return stored["access_token"]
  ```
</CodeGroup>

### Option B: Encrypted Cookie (Lightweight Scenarios)

For simple scenarios that don't require a database, you can encrypt Tokens and store them in HTTP-only cookies:

```typescript lib/token-cookie.ts theme={null}
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.PROFY_APP_SECRET!);

export async function encryptToken(payload: Record<string, unknown>) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("90d")
    .sign(secret);
}

export async function decryptToken(token: string) {
  const { payload } = await jwtVerify(token, secret);
  return payload;
}
```

<Warning>
  The cookie approach requires updating the cookie on every Token refresh and doesn't support server-side revocation. Option A is recommended for production environments.
</Warning>

## Step 7: Report Billing Events

<CodeGroup>
  ```typescript app/api/report-event/route.ts theme={null}
  import { NextRequest, NextResponse } from "next/server";
  import { profy } from "@/lib/profy";
  import { getValidToken } from "@/lib/token-store";
  import { cookies } from "next/headers";

  export async function POST(request: NextRequest) {
    const cookieStore = await cookies();
    const userId = cookieStore.get("profy_user_id")?.value;
    if (!userId) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const { eventName, idempotencyKey, metadata } = await request.json();
    const token = await getValidToken(userId);

    const result = await profy.reportEvent(eventName, {
      token,
      idempotencyKey,
      metadata,
    });

    return NextResponse.json(result);
  }
  ```

  ```python main.py (FastAPI) theme={null}
  from fastapi import FastAPI, Request, HTTPException
  from config import profy
  from token_store import get_valid_token

  @app.post("/api/report-event")
  async def report_event(request: Request):
      user_id = request.cookies.get("profy_user_id")
      if not user_id:
          raise HTTPException(status_code=401, detail="Unauthorized")

      body = await request.json()
      token = await get_valid_token(user_id)

      result = await profy.report_event(
          body["eventName"],
          token=token,
          idempotency_key=body.get("idempotencyKey"),
          metadata=body.get("metadata"),
      )

      return result
  ```
</CodeGroup>

Client-side usage example:

```tsx app/dashboard/page.tsx theme={null}
"use client";

import { useState } from "react";

export default function Dashboard() {
  const [balance, setBalance] = useState<number | null>(null);

  async function handleAction() {
    const res = await fetch("/api/report-event", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        eventName: "ai_generation",
        idempotencyKey: crypto.randomUUID(),
        metadata: { type: "text" },
      }),
    });

    if (!res.ok) {
      const err = await res.json();
      alert(err.error);
      return;
    }

    const { balance_remaining } = await res.json();
    setBalance(balance_remaining);
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4">
      <button
        onClick={handleAction}
        className="rounded-lg bg-green-600 px-6 py-3 text-white font-medium hover:bg-green-700 transition-colors"
      >
        Trigger AI Generation (Billed)
      </button>
      {balance !== null && (
        <p className="text-sm text-gray-500">Remaining balance: {balance}</p>
      )}
    </main>
  );
}
```

## Step 8: Error Handling

All errors thrown by the SDK are typed exceptions that can be caught precisely:

<CodeGroup>
  ```typescript lib/report-with-error-handling.ts theme={null}
  import { AuthExpired, InsufficientBalance, InvalidEvent, ProfyApiError } from "@profy-ai/sdk";
  import { profy } from "./profy";
  import { getValidToken, tokenStore } from "./token-store";

  export async function reportEventSafely(
    userId: string,
    eventName: string,
    options?: { idempotencyKey?: string; metadata?: Record<string, unknown> }
  ) {
    const token = await getValidToken(userId);

    try {
      return await profy.reportEvent(eventName, { token, ...options });
    } catch (error) {
      if (error instanceof AuthExpired) {
        await tokenStore.delete(userId);
        throw new Error("SESSION_EXPIRED");
      }
      if (error instanceof InsufficientBalance) {
        throw new Error("INSUFFICIENT_BALANCE");
      }
      if (error instanceof InvalidEvent) {
        throw new Error("INVALID_EVENT");
      }
      if (error instanceof ProfyApiError) {
        throw new Error(`PROFY_ERROR: ${error.message}`);
      }
      throw error;
    }
  }
  ```

  ```python report_event.py theme={null}
  from profy_sdk import AuthExpired, InsufficientBalance, InvalidEvent, ProfyApiError
  from config import profy
  from token_store import get_valid_token, token_store

  async def report_event_safely(
      user_id: str,
      event_name: str,
      idempotency_key: str | None = None,
      metadata: dict | None = None,
  ):
      token = await get_valid_token(user_id)

      try:
          return await profy.report_event(
              event_name,
              token=token,
              idempotency_key=idempotency_key,
              metadata=metadata,
          )
      except AuthExpired:
          await token_store.delete(user_id)
          raise Exception("SESSION_EXPIRED")
      except InsufficientBalance:
          raise Exception("INSUFFICIENT_BALANCE")
      except InvalidEvent:
          raise Exception("INVALID_EVENT")
      except ProfyApiError as e:
          raise Exception(f"PROFY_ERROR: {e}")
  ```
</CodeGroup>

Unified handling in the API Route:

```typescript app/api/report-event/route.ts theme={null}
import { NextRequest, NextResponse } from "next/server";
import { reportEventSafely } from "@/lib/report-with-error-handling";
import { cookies } from "next/headers";

export async function POST(request: NextRequest) {
  const cookieStore = await cookies();
  const userId = cookieStore.get("profy_user_id")?.value;
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { eventName, idempotencyKey, metadata } = await request.json();

  try {
    const result = await reportEventSafely(userId, eventName, {
      idempotencyKey,
      metadata,
    });
    return NextResponse.json(result);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";

    if (message === "SESSION_EXPIRED") {
      return NextResponse.json(
        { error: "Session expired. Please re-authorize." },
        { status: 401 }
      );
    }
    if (message === "INSUFFICIENT_BALANCE") {
      return NextResponse.json(
        { error: "Insufficient balance. Please top up and try again." },
        { status: 402 }
      );
    }
    if (message === "INVALID_EVENT") {
      return NextResponse.json(
        { error: "Invalid event name. Please check your Meter configuration." },
        { status: 400 }
      );
    }

    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}
```

## Complete Project Structure

```
profy-demo/
├── app/
│   ├── api/
│   │   ├── callback/
│   │   │   └── route.ts          # OAuth callback
│   │   └── report-event/
│   │       └── route.ts          # Billing event reporting
│   ├── dashboard/
│   │   └── page.tsx              # Post-login page
│   ├── layout.tsx
│   └── page.tsx                  # Login page
├── db/
│   └── schema.ts                 # Drizzle schema
├── lib/
│   ├── profy.ts                  # SDK singleton
│   ├── token-store.ts            # Token storage & refresh
│   └── report-with-error-handling.ts
├── .env.local
├── drizzle.config.ts
├── next.config.ts
├── package.json
└── tsconfig.json
```

## FAQ

<details>
  <summary>Getting an `invalid_redirect_uri` error on the callback?</summary>

  Make sure the `PROFY_CALLBACK_URL` in `.env.local` exactly matches the callback URL configured in the Profy developer dashboard (including protocol, port, and path).
</details>

<details>
  <summary>Can the old Token still be used after a refresh?</summary>

  No. After calling `refreshToken`, both the old access token and refresh token are invalidated (rotation mechanism). Always persist the new Token immediately after refreshing.
</details>

<details>
  <summary>`reportEvent` returns `InsufficientBalance` but the user just topped up?</summary>

  There's a brief cache delay for balance updates (typically \< 5s). If you need real-time balance, wait a few seconds after the top-up callback before retrying.
</details>

<details>
  <summary>How to test the OAuth flow in a development environment?</summary>

  Add `http://localhost:3000/api/callback` as a valid callback URL in the Profy developer dashboard. The OAuth flow in development works identically to production.
</details>

## Next Steps

<CardGroup cols={2}>
  <Card title="Per-Use Billing SaaS" icon="coins" href="/en/developers/cookbook/per-use-billing">
    PER\_USE mode: fixed-price per-use billing
  </Card>

  <Card title="AI Metered Billing" icon="gauge-high" href="/en/developers/cookbook/ai-metered-billing">
    METERED mode: billing by token consumption for AI model calls
  </Card>

  <Card title="Webhook Event Handling" icon="bell" href="/en/developers/cookbook/webhook-events">
    Receive real-time user event notifications from the Profy platform
  </Card>

  <Card title="Token Management Best Practices" icon="shield-check" href="/en/developers/cookbook/token-management">
    Multi-user management, concurrent refresh, secure storage
  </Card>
</CardGroup>
