This document outlines the standard pattern for integrating a modern React application (like the one in huf/frontend) with a Frappe backend. It explains how to deploy the React app as a Single Page Application (SPA) within Frappe without strictly relying on library wrappers like frappe-react-sdk or frappe-js-sdk. You can easily swap in react-query, standard fetch, or axios.
To serve a React app on a specific base route (e.g., /amuse instead of /huf), Frappe's website routing rules must be configured in hooks.py.
When a user navigates to /amuse/dashboard, Frappe needs to serve the React app's index.html file so that React Router can take over on the client side.
In hooks.py:
website_route_rules = [
# Catch-all for React app routing
{"from_route": "/amuse/<path:app_path>", "to_route": "amuse"},
# Base route
{"from_route": "/amuse", "to_route": "amuse"},
]This configuration tells Frappe: "Any request starting with /amuse should render the template amuse.html located in the www/ directory of your app."
The standard directory separation involves keeping React code in frontend/ and mapping built files into Frappe's structure (public/ and www/).
frontend/: The Vite/React source code.huf/public/frontend/: Where Vite outputs the built JS/CSS/image assets. Frappe makes anything inpublic/available at/assets/app_name/.huf/www/: Where the entry HTML file (amuse.html) lives. Frappe's web server renders files here for website routes.
The root package.json coordinates the build by delegating to the frontend folder:
{
"scripts": {
"build": "yarn build-frontend",
"build-frontend": "cd frontend && yarn install && yarn build"
}
}Inside frontend/package.json:
{
"scripts": {
"build": "tsc -b && vite build --base=/assets/amuse/frontend/ && yarn copy-html-entry",
"copy-html-entry": "cp ../amuse/public/frontend/index.html ../amuse/www/amuse.html"
}
}--base=/assets/amuse/frontend/: Instructs Vite that all generated<script>and<link>tags in the HTML should point to/assets/amuse/frontend/.... This matches how Frappe serves files from thepublicdirectory.copy-html-entry: Takes the builtindex.htmlfrom the public folder and places it intowww/amuse.html. Because of thewebsite_route_rulesinhooks.py, Frappe will map/amusedirectly to this file.
During development, you run the Vite dev server (e.g., http://localhost:8080). To avoid CORS issues and simulate the production environment where React and Frappe share the same origin, you proxy Frappe routes back to the Frappe server (e.g., http://localhost:8000).
In proxyOptions.ts (used by vite.config.ts):
const proxyOptions = {
'^/(app|api|assets|files|private|login)(/.*)?': {
target: 'http://localhost:8000', // Your Frappe dev server
changeOrigin: true,
secure: false,
ws: true, // Crucial for WebSockets
},
};This ensures API requests (/api/...) or socket connections hit Frappe seamlessly during development.
If you drop frappe-react-sdk, you can natively interact with Frappe's REST API using fetch + react-query.
Because the React app is served on the same origin as Frappe (both in dev through the proxy, and in prod via www/amuse.html), you do not need explicit API keys or token passing for logged-in users.
Frappe uses an HTTP-only cookie called sid (Session ID). Standard browser fetch will automatically include this cookie if you set credentials: 'include'.
For mutating requests (POST, PUT, DELETE), Frappe requires a CSRF token.
Frappe automatically embeds window.csrf_token in standard templates. For SPAs, you can ensure it's available by adding this to your www/amuse.html (or fetching it dynamically from /api/method/frappe.auth.get_logged_user).
Custom Fetch Wrapper (frappeClient.ts):
export const frappeFetch = async (endpoint: string, options: RequestInit = {}) => {
const isMutation = ['POST', 'PUT', 'DELETE'].includes(options.method?.toUpperCase() || '');
const headers = new Headers(options.headers || {});
headers.set('Content-Type', 'application/json');
headers.set('Accept', 'application/json');
if (isMutation && window.csrf_token) {
headers.set('X-Frappe-CSRF-Token', window.csrf_token);
}
const response = await fetch(endpoint, {
...options,
headers,
credentials: 'include', // Automatically passes the `sid` cookie for auth
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.exc || errorData._server_messages || `HTTP Error ${response.status}`);
}
return response.json();
};With the wrapper above, you can build a clean interface, perfectly suited for react-query:
1. Create (POST)
const createDocument = (doctype: string, data: any) => {
return frappeFetch(`/api/resource/${doctype}`, {
method: 'POST',
body: JSON.stringify(data),
});
};2. Read (GET)
const getDocument = (doctype: string, name: string) => {
return frappeFetch(`/api/resource/${doctype}/${name}`, { method: 'GET' });
};3. List (GET with filters)
const listDocuments = (doctype: string) => {
// Frappe expects filters/fields as JSON strings
const params = new URLSearchParams({
fields: JSON.stringify(["name", "title", "status"]),
filters: JSON.stringify([["status", "=", "Active"]]),
limit_page_length: "20"
});
return frappeFetch(`/api/resource/${doctype}?${params.toString()}`, { method: 'GET' });
};4. Update (PUT)
const updateDocument = (doctype: string, name: string, data: any) => {
return frappeFetch(`/api/resource/${doctype}/${name}`, {
method: 'PUT',
body: JSON.stringify(data),
});
};5. Call Custom RPC (Python Methods)
const callMethod = (methodPath: string, args: any) => {
return frappeFetch(`/api/method/${methodPath}`, {
method: 'POST', // Use POST for RPC with arguments
body: JSON.stringify(args),
});
};Frappe runs a realtime Node server using Socket.io. You can easily connect to it directly to listen for document events or custom published events from Python.
import { io } from "socket.io-client";
const setupWebSocket = () => {
const socket = io(window.location.origin, {
withCredentials: true, // Passes the `sid` cookie to authenticate the socket
path: "/socket.io"
});
socket.on('connect', () => {
console.log('Connected to Frappe realtime');
});
// Example: Frappe publishes doc updates here
socket.on('doc_update', (data) => {
console.log('Document updated:', data);
// Invalidate react-query cache here
});
// Example: Custom event triggered via frappe.publish_realtime() in Python
socket.on('my_custom_event', (data) => {
console.log('Custom data:', data);
});
return socket;
};Because of the shared origin and automatic cookie handling:
- You can use standard standard
tanstack/react-queryusing thefrappeFetchwrapper. - Form state can be handled exactly as you prefer (e.g.,
react-hook-form). - Realtime fits smoothly into React
useEffector React contexts. - Your
hooks.pycleanly maps the entire React frontend to the/amuseURL.