Consuming Frappe APIs in React/Next.js: A Decoupled Frontend Guide

Consuming Frappe APIs from a decoupled React and Next.js frontend

How to Connect React/Next.js to a Frappe Backend (API + Auth + Real-Time)

You inherited a Frappe Framework backend that works great for admins โ€” but your end users don't want a data-entry grid with sidebar filters. ๐Ÿ™… They want a fast, branded interface that feels like a product, not a back office. So can Frappe keep doing what it's good at (data modeling, permissions, workflows) while a completely separate React or Next.js app owns the actual experience?

Yes โ€” and it's a more common pattern than most Frappe docs let on. ๐Ÿ”Œ This post covers exactly how to wire the two together: which auth model to use when, how to dodge CORS and cookie landmines, and how to get live updates flowing into your components.


โฑ๏ธ Time to Complete (~35 minutes)

๐ŸŽฏ What You'll Achieve / Learn

  • ๐Ÿ”‘ Understand the two viable auth models for an external frontend โ€” API key vs. session cookie โ€” and when to use each
  • ๐ŸŒ Configure Frappe's CORS settings so a different-origin Next.js app can actually reach the API
  • ๐Ÿงฐ Build a small, reusable API client wrapper that unwraps Frappe's response envelope consistently
  • ๐Ÿ“„ Fetch, filter, and paginate list data from React using Frappe's standard list query parameters
  • โšก Wire up Socket.IO for real-time document updates, and understand deployment topology trade-offs

๐Ÿ› ๏ธ Prerequisites

  • โœ… A running Frappe site (local bench or a deployed instance) with at least one custom app installed
  • โœ… Basic familiarity with React and Next.js โ€” components, server components, and fetch
  • ๐Ÿ“š A general sense of how Frappe's REST API and @frappe.whitelist() endpoints work (covered in earlier posts on this blog) โ€” this post builds on that, it doesn't re-derive it

The Core Architecture ๐Ÿง 

Decoupled frontend architecture connecting Frappe, Next.js, React, REST, and Socket.IO

Think of Frappe as a headless backend here: doctypes, permissions, workflows, and business logic all still live there, but nothing in this setup depends on the Desk actually being rendered to your end users.

React or Next.js becomes the presentation layer โ€” it owns routing, styling, and every pixel your end users see. It talks to Frappe purely over HTTP (and, for live data, over a websocket), the same way it might talk to any other API.

The one architectural decision that shapes everything else is how your frontend authenticates. There are two real options, and mixing them up is the single most common source of confusion in decoupled Frappe setups.

Option A ๐Ÿ” โ€” API key/secret, called server-side. Your Next.js app's API routes or server components hold a Frappe API key and secret as server-only environment variables, and make authenticated requests on behalf of users. The browser never sees Frappe's credentials at all โ€” your Next.js server acts as a backend-for-frontend (BFF).

Option B ๐Ÿช โ€” session cookie, same-origin. The frontend is served from the same domain (or a subdomain sharing cookies) as Frappe, so after a normal Frappe login the browser already holds a valid sid session cookie. Requests from React include that cookie automatically, just like Desk requests do.

Neither is "more correct" โ€” they suit different deployment shapes, which is why topology gets its own step below. ๐Ÿ‘‡


Step 1: Decide Your Auth Model Before Writing Any Code

Choosing between API-token and session-cookie authentication with CORS and CSRF

Before touching CORS config or API clients, pin down which model you're building for โ€” it changes almost every step that follows.

๐Ÿš€ Pick API key/secret via a BFF if your Next.js app is public-facing, deployed independently (Vercel, its own container), and you don't want it to depend on sharing a domain with Frappe.

๐Ÿช Pick session cookie auth if you're serving both Frappe and Next.js from the same domain via a reverse proxy, and you want end users to get a normal browser session that behaves like any other cookie-authenticated app.

๐Ÿ’ก Pro Tip: A hybrid is common in practice โ€” session cookies for logged-in user actions, and a server-side API key for anonymous/public reads (like a public catalog page) that shouldn't require a login at all.


Step 2: Configure CORS on the Frappe Side

Step 2: Configure CORS on the Frappe Side

Section visual: Configure CORS on the Frappe Side.

If your Next.js app lives on a different origin than Frappe (e.g. app.yourdomain.com calling api.yourdomain.com), the browser will block the requests until Frappe explicitly allows it. Set this in site_config.json:

{
  "allow_cors": "https://app.yourdomain.com"
}

For session-cookie auth to survive cross-origin requests, you also need Frappe to send credentials-friendly headers, which is why allow_cors should be an explicit origin string rather than "*" โ€” wildcard origins and credentialed cookies are mutually exclusive in the CORS spec.

bench --site yoursite.local set-config allow_cors "https://app.yourdomain.com"

๐Ÿ”ง If you're running behind Nginx or another reverse proxy anyway, it's often cleaner to set the Access-Control-Allow-Origin and Access-Control-Allow-Credentials headers there instead, so CORS policy lives alongside your other proxy rules.

โš ๏ธ Common Mistake: Setting allow_cors to "*" and then wondering why session cookies never seem to authenticate requests from the browser. Wildcard CORS silently disables credentialed requests โ€” Frappe will look like it's ignoring your cookie entirely.


Step 3: Build a Small API Client Wrapper

Reusable Frappe API client with filters, pagination, and normalized errors

Frappe's REST responses aren't quite bare JSON โ€” list/get calls return { "data": ... }, while whitelisted method calls return { "message": ... }. Unwrap both consistently in one place instead of repeating this logic everywhere:

// lib/frappeClient.ts
const BASE_URL = process.env.FRAPPE_BASE_URL!; // server-only env var

type FrappeEnvelope<T> = { data?: T; message?: T };

async function frappeRequest<T>(
  path: string,
  init: RequestInit = {},
  useApiKey = true,
): Promise<T> {
  const headers: HeadersInit = {
    "Content-Type": "application/json",
    ...init.headers,
  };

  if (useApiKey) {
    // Server-side only โ€” never send this header from browser code
    headers["Authorization"] =
      `token ${process.env.FRAPPE_API_KEY}:${process.env.FRAPPE_API_SECRET}`;
  }

  const res = await fetch(`${BASE_URL}${path}`, {
    ...init,
    headers,
    credentials: useApiKey ? "omit" : "include", // cookies only in session mode
  });

  if (!res.ok) {
    throw new Error(`Frappe request failed: ${res.status} ${res.statusText}`);
  }

  const body: FrappeEnvelope<T> = await res.json();
  return (body.data ?? body.message) as T;
}

export const frappeGet = <T>(path: string) =>
  frappeRequest<T>(path, { method: "GET" });
export const frappePost = <T>(path: string, payload: unknown) =>
  frappeRequest<T>(path, { method: "POST", body: JSON.stringify(payload) });

Every call site now gets back plain data โ€” no .data, no .message, no guessing which envelope shape a given endpoint used.

๐Ÿ’ก Pro Tip: Keep this file server-only (inside lib/ and only imported from server components or API routes) if you're using the API-key branch โ€” importing it into a client component would bundle your secret into browser JavaScript.


Step 4: Fetch a List with Filters and Pagination

Step 4: Fetch a List with Filters and Pagination

Section visual: Fetch a List with Filters and Pagination.

Frappe's list endpoint (/api/resource/<Doctype>) takes fields, filters, and limit_page_length as query params. Building these from React state is just URL construction:

// lib/getInvoices.ts
import { frappeGet } from "./frappeClient";

interface ListParams {
  page?: number;
  pageSize?: number;
  status?: string;
}

export async function getInvoices({
  page = 1,
  pageSize = 20,
  status,
}: ListParams) {
  const filters = status
    ? JSON.stringify([["status", "=", status]])
    : undefined;

  const params = new URLSearchParams({
    fields: JSON.stringify(["name", "customer", "status", "grand_total"]),
    limit_page_length: String(pageSize),
    limit_start: String((page - 1) * pageSize),
    ...(filters ? { filters } : {}),
  });

  return frappeGet<any[]>(`/api/resource/Sales Invoice?${params.toString()}`);
}

๐Ÿ“Œ Note that filters has to be a JSON-encoded array of arrays, not a plain object โ€” that's Frappe's list-view filter syntax, and it trips up almost everyone the first time.


Step 5: Call a Custom Whitelisted Endpoint

Step 5: Call a Custom Whitelisted Endpoint

Section visual: Call a Custom Whitelisted Endpoint.

For anything beyond simple CRUD โ€” a computed dashboard summary, a multi-doctype action โ€” you'll be calling a custom @frappe.whitelist() method rather than the generic resource endpoint:

import { frappePost } from "./frappeClient";

export async function approveInvoice(invoiceName: string) {
  return frappePost<{ status: string }>(
    "/api/method/your_app.api.invoice.approve_invoice",
    { invoice_name: invoiceName },
  );
}

If you haven't written one of these yet, the mechanics of @frappe.whitelist() โ€” decorators, allow_guest, argument parsing โ€” are covered in a separate post on this blog; this step assumes that endpoint already exists and just focuses on calling it cleanly from React.


Step 6: Wire It Into a Next.js Server Component

Next.js server component calls compared with browser session calls to Frappe

With the client wrapper in place, a server component can fetch and render data with zero client-side secret exposure:

// app/invoices/page.tsx
import { getInvoices } from "@/lib/getInvoices";

export default async function InvoicesPage() {
  const invoices = await getInvoices({
    page: 1,
    pageSize: 20,
    status: "Unpaid",
  });

  return (
    <main>
      <h1>Unpaid Invoices</h1>
      <ul>
        {invoices.map((inv) => (
          <li key={inv.name}>
            {inv.customer} โ€” {inv.grand_total}
          </li>
        ))}
      </ul>
    </main>
  );
}

๐Ÿ”’ Because this runs on the server, FRAPPE_API_KEY and FRAPPE_API_SECRET never reach the client bundle โ€” the browser only ever receives the already-rendered HTML and the plain invoice data.

โš ๏ธ Common Mistake: Copy-pasting this same fetch logic into a "use client" component with NEXT_PUBLIC_FRAPPE_API_KEY. The NEXT_PUBLIC_ prefix ships the value straight into browser JavaScript โ€” anyone can open dev tools and read your API secret.


Step 7: Handle Session Auth and CSRF for Same-Domain Setups

Step 7: Handle Session Auth and CSRF for Same-Domain Setups

Section visual: Handle Session Auth and CSRF for Same-Domain Setups.

If you're on the session-cookie path instead, login happens through Frappe's normal /api/method/login endpoint, and the browser stores the resulting sid cookie automatically. Every subsequent fetch just needs credentials: "include".

The catch: any write (POST/PUT/DELETE) under session auth needs an X-Frappe-CSRF-Token header, which Frappe injects into the page when Desk itself renders โ€” but your React app isn't Desk, so you need to fetch it explicitly:

const csrfRes = await fetch(
  `${BASE_URL}/api/method/frappe.client.get_csrf_token`,
  {
    credentials: "include",
  },
);
const { message: csrfToken } = await csrfRes.json();

await fetch(`${BASE_URL}/api/resource/Sales Invoice`, {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-Frappe-CSRF-Token": csrfToken,
  },
  body: JSON.stringify({ customer: "Acme Co" }),
});

โš ๏ธ Common Mistake: Session-based GET requests work fine without a CSRF token, which makes it easy to ship the whole app, only to have every write silently fail (or 400) the moment a user tries to submit a form.

๐Ÿช Also make sure cookies are configured for cross-subdomain sharing if app.yourdomain.com and api.yourdomain.com differ โ€” that means SameSite=Lax (or None with Secure if truly cross-site) and the cookie domain set broadly enough to cover both.


Step 8: Connect to Socket.IO for Real-Time Updates

Realtime and deployment topology for Next.js, Frappe API, Socket.IO, and reverse proxy

Frappe ships a Socket.IO server that broadcasts document events (doc_update, list_update) to connected clients โ€” the same mechanism Desk uses to live-refresh list views. A React app can subscribe to the same events:

// lib/realtime.ts
import { io } from "socket.io-client";

export function subscribeToInvoiceUpdates(onUpdate: (doc: any) => void) {
  const socket = io(process.env.NEXT_PUBLIC_FRAPPE_SOCKET_URL!, {
    withCredentials: true,
  });

  socket.on("connect", () => {
    socket.emit("doctype_subscribe", "Sales Invoice");
  });

  socket.on("doc_update", (data) => {
    if (data.doctype === "Sales Invoice") {
      onUpdate(data);
    }
  });

  return () => socket.disconnect();
}

๐Ÿ”„ In a client component, call this inside a useEffect and merge incoming updates into local state โ€” this is how you get a list view that updates live without polling.

๐Ÿ’ก Pro Tip: The Socket.IO server is a separate process/port from the main HTTP API (often proxied at /socket.io in production). Confirm your reverse proxy forwards websocket upgrade headers, or connections will silently fall back to polling or fail outright.

Real-time here is genuinely conceptual scaffolding rather than a drop-in library โ€” the exact event payloads are worth logging once in dev to confirm the shape before you build UI around them.


Step 9: Choose Your Deployment Topology

Step 9: Choose Your Deployment Topology

Section visual: Choose Your Deployment Topology.

๐Ÿ  Same domain, path-based routing โ€” Frappe Desk lives at /app, your Next.js frontend owns /, both behind one reverse proxy on one domain. Cookies just work, CSRF tokens work, CORS config is barely needed since everything is same-origin.

๐ŸŒ Separate subdomains โ€” app.yourdomain.com for the frontend, api.yourdomain.com for Frappe. Cleaner separation of deploy pipelines (Vercel for Next.js, your own infra for Frappe), but now you're fully in CORS-and-cookie-domain territory from Step 2 onward.

โœ… For most teams, same-domain path routing is the simpler starting point โ€” you only reach for subdomains when the two apps genuinely need independent scaling, deploy cadences, or hosting providers.

โš ๏ธ Common Mistake: Choosing separate subdomains "for cleanliness" and then discovering session auth basically doesn't work in Safari's default cross-site cookie blocking โ€” at that point the API-key/BFF model in Step 1 becomes close to mandatory.


Common Gotchas & Troubleshooting ๐Ÿ› ๏ธ

Common Gotchas & Troubleshooting

Section visual: Common Gotchas & Troubleshooting.

  • ๐Ÿšซ CORS errors in the browser console: almost always means allow_cors doesn't exactly match the requesting origin (protocol + domain + port all matter) โ€” wildcard "*" won't fix credentialed requests
  • ๐Ÿช Cookie not sent cross-origin: check SameSite and Secure attributes; SameSite=Lax blocks cookies on many cross-site requests, and Secure cookies require HTTPS on both ends
  • โœ๏ธ Writes fail with 403 under session auth: you forgot the X-Frappe-CSRF-Token header โ€” GETs will work fine and mask this until someone submits a form
  • ๐Ÿ•ต๏ธ API secret visible in browser dev tools: a NEXT_PUBLIC_-prefixed env var or a client component importing the API-key wrapper directly โ€” audit your bundle if this happens
  • ๐Ÿ”€ Mixing auth models mid-request: sending both an Authorization: token ... header and credentials: "include" on the same call is redundant and can produce confusing permission errors โ€” pick one model per request
  • ๐Ÿ”Œ Socket.IO never connects: usually a reverse proxy not forwarding Upgrade/Connection headers for the websocket handshake

Recommended Project Structure ๐Ÿ“

your-project/
โ”œโ”€โ”€ frappe-bench/
โ”‚   โ””โ”€โ”€ apps/
โ”‚       โ””โ”€โ”€ your_custom_app/
โ”‚           โ”œโ”€โ”€ your_custom_app/
โ”‚           โ”‚   โ””โ”€โ”€ api/
โ”‚           โ”‚       โ””โ”€โ”€ invoice.py        # @frappe.whitelist() methods
โ”‚           โ””โ”€โ”€ your_custom_app/hooks.py
โ”‚
โ””โ”€โ”€ frontend/                              # separate Next.js repo/deploy
    โ”œโ”€โ”€ app/
    โ”‚   โ”œโ”€โ”€ invoices/
    โ”‚   โ”‚   โ””โ”€โ”€ page.tsx                   # server component, calls lib/
    โ”‚   โ””โ”€โ”€ api/
    โ”‚       โ””โ”€โ”€ invoices/route.ts          # optional BFF route
    โ”œโ”€โ”€ lib/
    โ”‚   โ”œโ”€โ”€ frappeClient.ts                # envelope-unwrapping wrapper
    โ”‚   โ”œโ”€โ”€ getInvoices.ts
    โ”‚   โ””โ”€โ”€ realtime.ts                    # Socket.IO subscription helper
    โ””โ”€โ”€ .env.local                         # FRAPPE_API_KEY, FRAPPE_API_SECRET (server-only)

๐Ÿ“ฆ Keeping the frontend in its own repo (even if deployed to the same domain via proxy config) reinforces the mental model โ€” Frappe doesn't know or care that React exists on the other side of the API.


Final Thoughts ๐Ÿ’ก

Decoupling a Frappe frontend isn't exotic ๐Ÿงฉ โ€” it's the same headless-backend pattern you'd use with any API, just with Frappe-specific envelope shapes and auth quirks to respect. Get the auth model and CORS/cookie config right up front, and everything downstream โ€” fetching, filtering, real-time updates โ€” is ordinary React work. The framework stays the system of record; your React or Next.js app just gets to be the interface your users actually deserve. โœจ


References ๐Ÿ”—

Related posts