If you read my last post on Salesforce breaking up with its own UI, you saw the strategy. This post is the implementation.
We are going to take Salesforce — the data, the security model, the business logic — and put a custom React frontend on top of it. No Lightning. No LWC. Just a modern React app talking to Salesforce as a backend, the way you would talk to any other API.
By the end of this guide, you will have a working application with authentication, real CRUD operations, role-aware data, and patterns you can take into production. This is not a “Hello World” toy. This is the actual playbook.
The TL;DR
If you want the punchline before the journey:
- Headless Salesforce + React works today, even without the official “Headless 360” branding. The APIs have been there for years.
- The hardest parts are authentication (use OAuth 2.0 Web Server Flow with PKCE) and CORS (configure it once and forget it).
- You can ship the React app on any host (Vercel, Netlify, S3+CloudFront), or use Experience Cloud if you want auth and hosting bundled.
- Salesforce sharing rules and FLS still apply. You cannot bypass governance by going headless. That is a feature, not a bug.
- The real win is decoupling release cycles — UI changes ship in minutes, backend logic still lives in Salesforce.
The rest of this guide takes you from zero to a working app.
The Business Case: When Does This Make Sense?
Before writing a single line of code, let us be clear about when headless Salesforce + React is actually the right call. Pulling Salesforce out of the browser is a non-trivial commitment, and it is not always the right answer.
Good fit:
- Customer-facing portals where Salesforce-styled UIs feel out of place. A SaaS company exposing their CRM data to end customers does not want their app to look like a Salesforce org.
- High-traffic public sites where you need CDN-level performance, edge caching, and full control over the rendering pipeline. Lightning cannot match a Next.js app behind a CDN for raw page-load speed.
- Brand-critical experiences where pixel-perfect design and motion are not negotiable. Marketing landing pages, partner portals, premium customer experiences.
- Mobile-first apps where a React Native or Expo client is fronting Salesforce data and you need the API to be the contract, not the UI.
- AI-driven interfaces where the “UI” is partly an agent and partly a human dashboard — you need both to share the same backend.
Bad fit:
- Internal CRM users doing standard sales/service work. Lightning Experience is genuinely good for this, and rebuilding it is expensive and pointless.
- Admin-heavy workflows with declarative tools (page layouts, list views, record types). Reimplementing these in React is a multi-quarter project.
- Reports and dashboards. Salesforce’s reporting engine is hard to beat without significant engineering investment.
The Concrete Scenario We Are Building
Throughout this guide, we will build a Customer Portal for a fictional logistics company, “Cargo Flow.” Their customers — small businesses shipping pallets across North America — need to:
- Log in securely with their email and password.
- See a list of their active shipments with live status updates.
- View details for any shipment, including delivery timeline and contact info.
- Update delivery instructions (a write operation back to Salesforce).
- Have everything respect Salesforce sharing rules — customers see only their own data, never other customers’.
Cargo Flow’s internal team continues using Lightning Experience for operations. The headless portal is purely customer-facing. This is the typical architecture in 2026, and it is what we will build.
The Architecture Map
Before diving into the code, here is the high-level architecture so you have a mental model for where each piece fits.
┌───────────────────────────────┐
│ React App (Vercel/Netlify) │
│ - Login screen │
│ - Shipment dashboard │
│ - Detail views │
└──────────────┬────────────────┘
│ HTTPS + Bearer Token
▼
┌───────────────────────────────┐
│ Salesforce Connected App │
│ - OAuth 2.0 (PKCE) │
│ - CORS allowlist │
│ - Scoped API access │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ Salesforce Platform │
│ - Shipment__c records │
│ - Sharing rules + FLS │
│ - Validation + automation │
│ - Apex REST (custom logic) │
└───────────────────────────────┘
Three components. Each one solves a specific problem:
- The React app owns presentation, routing, and user experience. It knows nothing about how Salesforce stores data internally.
- The Connected App owns authentication and authorization. It is the security boundary between the internet and your org.
- The Salesforce platform owns the data, the business rules, and the governance model. It is the source of truth.
The contract between these layers is HTTPS + OAuth + JSON. That is it. No SOAP, no custom protocols, no Salesforce-specific SDK required.
Step 1: Set Up the Salesforce Org
I am going to assume you have a Salesforce Developer Edition org. If not, sign up at developer.salesforce.com — it takes five minutes and it is free forever.
1.1 Create the Custom Object
Inside Setup, create a custom object called Shipment__c with these fields:
| Field Label | API Name | Type | Notes |
|---|---|---|---|
| Tracking Number | Tracking_Number__c | Text(20) | External ID, Required |
| Origin | Origin__c | Text(100) | |
| Destination | Destination__c | Text(100) | |
| Status | Status__c | Picklist | Values: Booked, In Transit, Delivered, Exception |
| Estimated Delivery | Estimated_Delivery__c | DateTime | |
| Delivery Instructions | Delivery_Instructions__c | Long Text Area | |
| Customer Account | Customer_Account__c | Lookup(Account) | Required |
Create five to ten sample records linked to a test Account. This will give you data to work with throughout the build.
1.2 Configure Sharing
In Setup → Sharing Settings, set the Organization-Wide Default for Shipment__c to Private. Then create a sharing rule that grants Read access to the Customer Community User profile based on the Customer_Account__c field matching the user’s contact’s account.
This is the single most important step in the entire build. Salesforce’s sharing model is what makes headless safe. The React app will never have to enforce “Customer A cannot see Customer B’s shipments” — the platform does that automatically, on every query, regardless of how the API is called.
If you are tempted to skip the sharing rule because “the React app will handle it” — stop. Do not do that. Sharing rules are a hard security boundary. App-level filtering is a soft one. Always prefer the hard one.
Step 2: Create the Connected App
The Connected App is what allows your React frontend to talk to Salesforce. Without it, every request will be rejected.
Navigate to Setup → App Manager → New Connected App.
2.1 Basic Settings
- Connected App Name:
Cargo Flow Customer Portal - API Name:
Cargo_Flow_Customer_Portal - Contact Email: Your email
- Enable OAuth Settings: ✅
- Callback URL:
http://localhost:5173/oauth/callback(and your production URL when you ship) - Use digital signatures: ❌ (we are using PKCE instead)
- Selected OAuth Scopes:
- Manage user data via APIs (
api) - Perform requests at any time (
refresh_token, offline_access) - Access the identity URL service (
id, profile, email, address, phone)
- Manage user data via APIs (
- Require Proof Key for Code Exchange (PKCE): ✅
- Require Secret for Web Server Flow: ❌ (we are a public client; PKCE replaces the secret)
Save. Wait two to ten minutes for Salesforce to propagate the changes. Yes, this is annoying. No, there is no way to speed it up. Make coffee.
2.2 Note Your Credentials
After saving, you will get:
- Consumer Key (this is your
client_id) - Consumer Secret (you do not need this for PKCE, but note it)
Copy the Consumer Key into a note. You will need it shortly.
2.3 Configure CORS
Without CORS, your browser will block every request from your React app. In Setup → CORS → New, add:
http://localhost:5173(for development)https://your-production-domain.com(for production)
CORS in Salesforce is allowlist-only. There is no wildcard. Add every origin you intend to support.
2.4 Configure the Policy
Back in your Connected App, click Manage → Edit Policies:
- Permitted Users: Admin approved users are pre-authorized
- IP Relaxation: Relax IP restrictions (for development; tighten in production)
- Refresh Token Policy: Refresh token is valid until revoked
Then assign your Connected App to the Customer Community User profile (or create a Permission Set) so the customers can actually log in through it.
Step 3: Scaffold the React App
Now we leave Salesforce and switch to the frontend. Open a terminal.
npm create vite@latest cargo-flow-portal -- --template react-ts
cd cargo-flow-portal
npm install
npm install react-router-dom axios
npm install -D @types/node
Vite + React + TypeScript. No Next.js for this guide — we are keeping the deployment story simple. The same patterns work in Next.js, Remix, or any modern React framework.
3.1 Environment Configuration
Create a .env.local file in the project root:
VITE_SF_LOGIN_URL=https://login.salesforce.com
VITE_SF_CLIENT_ID=YOUR_CONSUMER_KEY_HERE
VITE_SF_REDIRECT_URI=http://localhost:5173/oauth/callback
VITE_SF_API_VERSION=v62.0
Add .env.local to your .gitignore immediately. Never commit credentials, even Consumer Keys.
3.2 Project Structure
src/
├── auth/
│ ├── pkce.ts // PKCE code generation
│ ├── authService.ts // Login, logout, token management
│ └── AuthContext.tsx // React context for auth state
├── api/
│ └── salesforce.ts // Axios client for SF REST API
├── pages/
│ ├── Login.tsx
│ ├── Dashboard.tsx
│ ├── ShipmentDetail.tsx
│ └── OAuthCallback.tsx
└── App.tsx
We will build each piece in order.
Step 4: Implement OAuth 2.0 with PKCE
This is the most error-prone part of the entire build. Get it right once, copy it forever.
4.1 PKCE Helpers
src/auth/pkce.ts:
// Generates the cryptographic challenge that protects against
// authorization code interception attacks. Required for public clients.
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return base64UrlEncode(new Uint8Array(digest));
}
function base64UrlEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
PKCE — Proof Key for Code Exchange — is what makes OAuth safe for public clients like a single-page React app. The browser generates a random verifier, sends only a hash of it (the challenge) to Salesforce, and proves possession of the original verifier when exchanging the authorization code for an access token. An attacker who intercepts the authorization code cannot use it without the original verifier.
4.2 The Auth Service
src/auth/authService.ts:
import { generateCodeVerifier, generateCodeChallenge } from "./pkce";
const LOGIN_URL = import.meta.env.VITE_SF_LOGIN_URL;
const CLIENT_ID = import.meta.env.VITE_SF_CLIENT_ID;
const REDIRECT_URI = import.meta.env.VITE_SF_REDIRECT_URI;
export async function initiateLogin(): Promise<void> {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Store the verifier so we can use it on the callback
sessionStorage.setItem("pkce_verifier", verifier);
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_challenge: challenge,
code_challenge_method: "S256",
scope: "api refresh_token id",
});
window.location.href = `${LOGIN_URL}/services/oauth2/authorize?${params}`;
}
export async function handleCallback(code: string): Promise<TokenResponse> {
const verifier = sessionStorage.getItem("pkce_verifier");
if (!verifier) throw new Error("Missing PKCE verifier");
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
const response = await fetch(`${LOGIN_URL}/services/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokens: TokenResponse = await response.json();
sessionStorage.removeItem("pkce_verifier");
storeTokens(tokens);
return tokens;
}
export function getAccessToken(): string | null {
return sessionStorage.getItem("sf_access_token");
}
export function getInstanceUrl(): string | null {
return sessionStorage.getItem("sf_instance_url");
}
export function logout(): void {
sessionStorage.clear();
window.location.href = "/login";
}
function storeTokens(tokens: TokenResponse): void {
sessionStorage.setItem("sf_access_token", tokens.access_token);
sessionStorage.setItem("sf_instance_url", tokens.instance_url);
if (tokens.refresh_token) {
sessionStorage.setItem("sf_refresh_token", tokens.refresh_token);
}
}
interface TokenResponse {
access_token: string;
instance_url: string;
refresh_token?: string;
id: string;
token_type: string;
issued_at: string;
}
A few things worth noting:
- We use
sessionStorageinstead oflocalStorage. Tokens disappear when the tab closes. This is intentional — it dramatically reduces the blast radius of an XSS attack. instance_urlcomes back from Salesforce in the token response. This is the URL your API calls go to (e.g.,https://yourorg.my.salesforce.com), not the login URL. Save it.- For production, consider moving the token exchange to a backend-for-frontend (BFF) so the refresh token never lives in the browser at all. Sessions live server-side, the browser holds an HTTP-only cookie. This is the gold standard.
4.3 The Auth Context
src/auth/AuthContext.tsx:
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { getAccessToken, logout as doLogout } from "./authService";
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
logout: () => void;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = getAccessToken();
setIsAuthenticated(!!token);
setIsLoading(false);
}, []);
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout: doLogout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
Simple, but enough for our needs. In production you would expand this with token refresh logic and idle timeout handling.
Step 5: Build the Salesforce API Client
This is where the actual data movement happens. Every API call follows the same pattern: get the access token, attach it as a Bearer header, send the request to the instance URL.
src/api/salesforce.ts:
import axios, { AxiosInstance } from "axios";
import { getAccessToken, getInstanceUrl, logout } from "../auth/authService";
const API_VERSION = import.meta.env.VITE_SF_API_VERSION;
function createClient(): AxiosInstance {
const instance = axios.create({
baseURL: `${getInstanceUrl()}/services/data/${API_VERSION}`,
headers: {
Authorization: `Bearer ${getAccessToken()}`,
"Content-Type": "application/json",
},
});
// Auto-logout on 401 (token expired)
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
logout();
}
return Promise.reject(error);
}
);
return instance;
}
export interface Shipment {
Id: string;
Name: string;
Tracking_Number__c: string;
Origin__c: string;
Destination__c: string;
Status__c: string;
Estimated_Delivery__c: string;
Delivery_Instructions__c: string;
}
export async function listShipments(): Promise<Shipment[]> {
const client = createClient();
const soql = `
SELECT Id, Name, Tracking_Number__c, Origin__c, Destination__c,
Status__c, Estimated_Delivery__c, Delivery_Instructions__c
FROM Shipment__c
ORDER BY Estimated_Delivery__c DESC
LIMIT 50
`;
const response = await client.get(`/query?q=${encodeURIComponent(soql)}`);
return response.data.records;
}
export async function getShipment(id: string): Promise<Shipment> {
const client = createClient();
const response = await client.get(`/sobjects/Shipment__c/${id}`);
return response.data;
}
export async function updateShipmentInstructions(
id: string,
instructions: string
): Promise<void> {
const client = createClient();
await client.patch(`/sobjects/Shipment__c/${id}`, {
Delivery_Instructions__c: instructions,
});
}
Three operations:
listShipments— uses SOQL via the/queryendpoint. Salesforce’s sharing rules automatically filter results to only the shipments this user is allowed to see. You writeSELECT ... FROM Shipment__cand the platform does the security work.getShipment— uses the sObject REST API to fetch a single record by Id. Same sharing rules apply.updateShipmentInstructions— PATCH request to update a single field. Validation rules, before-save flows, and field-level security all fire as if a user typed the value into the Lightning UI.
This is the magic of headless Salesforce. The platform does not care whether the request came from Lightning, a React app, an iPhone, or a Python script. The same governance applies.
Step 6: Build the Pages
Now we wire up the React UI. I will show abbreviated versions to keep this readable — the full pattern should be clear.
6.1 Login Page
src/pages/Login.tsx:
import { initiateLogin } from "../auth/authService";
export default function Login() {
return (
<div className="login-container">
<h1>Cargo Flow Customer Portal</h1>
<p>Track your shipments in real time.</p>
<button onClick={() => initiateLogin()}>
Sign in with Salesforce
</button>
</div>
);
}
One button. It kicks off the OAuth flow. The user is redirected to Salesforce, logs in there, and comes back authenticated. You never touch the user’s password.
6.2 OAuth Callback Page
src/pages/OAuthCallback.tsx:
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { handleCallback } from "../auth/authService";
export default function OAuthCallback() {
const [params] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const code = params.get("code");
const error = params.get("error");
if (error) {
console.error("OAuth error:", error);
navigate("/login");
return;
}
if (code) {
handleCallback(code)
.then(() => navigate("/dashboard"))
.catch((err) => {
console.error("Token exchange failed:", err);
navigate("/login");
});
}
}, [params, navigate]);
return <div>Signing you in...</div>;
}
6.3 Dashboard
src/pages/Dashboard.tsx:
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { listShipments, Shipment } from "../api/salesforce";
import { useAuth } from "../auth/AuthContext";
export default function Dashboard() {
const { logout } = useAuth();
const [shipments, setShipments] = useState<Shipment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listShipments()
.then(setShipments)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading shipments...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="dashboard">
<header>
<h1>Your Shipments</h1>
<button onClick={logout}>Sign out</button>
</header>
<table>
<thead>
<tr>
<th>Tracking #</th>
<th>Origin</th>
<th>Destination</th>
<th>Status</th>
<th>ETA</th>
<th></th>
</tr>
</thead>
<tbody>
{shipments.map((s) => (
<tr key={s.Id}>
<td>{s.Tracking_Number__c}</td>
<td>{s.Origin__c}</td>
<td>{s.Destination__c}</td>
<td>
<StatusBadge status={s.Status__c} />
</td>
<td>{new Date(s.Estimated_Delivery__c).toLocaleDateString()}</td>
<td>
<Link to={`/shipments/${s.Id}`}>View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
Booked: "#94a3b8",
"In Transit": "#3b82f6",
Delivered: "#10b981",
Exception: "#ef4444",
};
return (
<span style={{ background: colors[status], color: "white", padding: "2px 8px", borderRadius: 4 }}>
{status}
</span>
);
}
6.4 Shipment Detail with Write Support
src/pages/ShipmentDetail.tsx:
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getShipment, updateShipmentInstructions, Shipment } from "../api/salesforce";
export default function ShipmentDetail() {
const { id } = useParams<{ id: string }>();
const [shipment, setShipment] = useState<Shipment | null>(null);
const [instructions, setInstructions] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!id) return;
getShipment(id).then((s) => {
setShipment(s);
setInstructions(s.Delivery_Instructions__c || "");
});
}, [id]);
async function handleSave() {
if (!id) return;
setSaving(true);
try {
await updateShipmentInstructions(id, instructions);
alert("Saved!");
} catch (e) {
alert("Save failed: " + (e as Error).message);
} finally {
setSaving(false);
}
}
if (!shipment) return <div>Loading...</div>;
return (
<div className="detail">
<h1>Shipment {shipment.Tracking_Number__c}</h1>
<dl>
<dt>Origin</dt><dd>{shipment.Origin__c}</dd>
<dt>Destination</dt><dd>{shipment.Destination__c}</dd>
<dt>Status</dt><dd>{shipment.Status__c}</dd>
<dt>ETA</dt><dd>{new Date(shipment.Estimated_Delivery__c).toLocaleString()}</dd>
</dl>
<h2>Delivery Instructions</h2>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={4}
cols={60}
/>
<button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Instructions"}
</button>
</div>
);
}
This is the write path. When the user clicks Save:
- The React app PATCHes the field via REST API.
- Salesforce checks field-level security on
Delivery_Instructions__c. - Any validation rules on
Shipment__cfire. - Any record-triggered flow runs.
- The platform writes the change and returns success or error.
You did not have to implement any of that. The platform handled it. This is the “Salesforce as backend” pattern in action.
Step 7: Wire Up Routing
src/App.tsx:
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth/AuthContext";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import ShipmentDetail from "./pages/ShipmentDetail";
import OAuthCallback from "./pages/OAuthCallback";
function ProtectedRoute({ children }: { children: JSX.Element }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return children;
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route
path="/dashboard"
element={<ProtectedRoute><Dashboard /></ProtectedRoute>}
/>
<Route
path="/shipments/:id"
element={<ProtectedRoute><ShipmentDetail /></ProtectedRoute>}
/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
Run npm run dev, visit http://localhost:5173, click “Sign in with Salesforce,” log in with your Customer Community User, and you should see your shipments.
If you get an error, 99% of the time it is one of:
- CORS not configured — go back to Setup → CORS and add
http://localhost:5173. - Wrong callback URL — must match exactly in both the Connected App and
.env.local. - User profile missing API access — the Customer Community User profile needs “API Enabled.”
- Connected App not propagated yet — wait ten minutes after creating it.
Step 8: Add Custom Apex REST for Complex Logic
Standard REST APIs are great for CRUD. But what about a business operation like “Cancel a shipment, refund the credit card, and notify the warehouse”? That is three operations in a transaction, and you do not want to orchestrate it from the React client.
This is where Apex REST comes in. Write the business logic in Apex, expose it as a custom endpoint, call it from React.
8.1 Apex Class
@RestResource(urlMapping='/customer/shipments/*/cancel')
global class ShipmentCancelService {
@HttpPost
global static CancelResponse cancel() {
RestRequest req = RestContext.request;
String shipmentId = req.requestURI.substringBetween(
'/customer/shipments/', '/cancel'
);
Shipment__c shipment = [
SELECT Id, Status__c, Customer_Account__c
FROM Shipment__c
WHERE Id = :shipmentId
WITH SECURITY_ENFORCED
LIMIT 1
];
if (shipment.Status__c == 'Delivered') {
return new CancelResponse(false, 'Cannot cancel a delivered shipment.');
}
shipment.Status__c = 'Cancelled';
update shipment;
// Trigger downstream automation (refund, notification, etc.)
// via Platform Events or Flow
return new CancelResponse(true, 'Shipment cancelled successfully.');
}
global class CancelResponse {
global Boolean success;
global String message;
global CancelResponse(Boolean success, String message) {
this.success = success;
this.message = message;
}
}
}
The WITH SECURITY_ENFORCED clause is critical. It tells Salesforce to enforce field-level security and object permissions exactly as if the user had typed the SOQL in the UI. Without it, you have a security hole.
8.2 Call It from React
export async function cancelShipment(id: string): Promise<{ success: boolean; message: string }> {
const client = createClient();
const baseURL = `${getInstanceUrl()}/services/apexrest/customer/shipments/${id}/cancel`;
const response = await client.post(baseURL, {});
return response.data;
}
The pattern: REST for CRUD, Apex REST for transactions and business logic. You get to choose where each piece of logic lives.
Step 9: Production Considerations
Getting to a working localhost demo is the easy 80%. Shipping to production is the painful 20%. Here is what you need to think about.
Token Storage
sessionStorage is fine for proof-of-concept. For production, move to a backend-for-frontend pattern. A small Node/Deno/Bun server holds the refresh token, the browser only sees an HTTP-only cookie. This eliminates the entire class of XSS-to-credential-theft attacks. Yes, it is more infrastructure. Yes, it is worth it.
Rate Limits
Salesforce enforces API call limits per 24 hours, scoped per org. A high-traffic public portal can chew through these in an afternoon. Strategies:
- Cache aggressively. Static reference data (status picklists, country lists) should live in your React build, not be fetched every page load.
- Use the Composite API to bundle multiple operations into one request when possible.
- Watch the limits via the
Sforce-Limit-Inforesponse header. Set up alerts in your APM. - Consider Data Cloud for high-volume read patterns where Salesforce’s transactional database is overkill.
CORS, CSP, and Security Headers
Your Vercel/Netlify deployment should set:
Content-Security-Policyrestricting where scripts can come fromStrict-Transport-Security(HSTS) to force HTTPSX-Frame-Options: DENYto prevent clickjackingReferrer-Policy: strict-origin-when-cross-origin
These are not Salesforce-specific. They are just good hygiene that the Lightning platform handles for you and that you now have to handle yourself.
Observability
Lightning gives you free debug logs, performance traces, and event monitoring. In headless mode, you bring your own:
- Sentry or Datadog for frontend errors
- Salesforce Event Monitoring for API call patterns and slow queries
- Apex Limit Exceptions logged with enough context to actually debug
CI/CD
Your React app and your Salesforce metadata are now two separate deployment pipelines. They need to stay in sync. A breaking change to a custom field — a renamed picklist value, a changed API name — can take down the React app. Strategies:
- Schema contract tests that run in CI and verify the Salesforce metadata your React app depends on still exists with the expected shape.
- Feature flags in the React app that let you ship UI changes ahead of backend changes, then enable them when both sides are deployed.
- API versioning on your Apex REST endpoints. Never change the contract of
/v1/; ship a new/v2/.
Step 10: When to Use Experience Cloud Instead
Everything above assumes you are hosting the React app yourself on Vercel, Netlify, or similar. That gives you maximum flexibility, edge caching, and a deployment story you control.
But there is a tempting alternative: host the React app inside Experience Cloud, on Salesforce’s own infrastructure.
| Self-Hosted React | Experience Cloud + React | |
|---|---|---|
| Hosting cost | Whatever Vercel charges | Bundled into Salesforce licensing |
| Authentication | OAuth + PKCE, you implement | Built-in, just works |
| CDN performance | Edge-cached, very fast | Slower than a dedicated CDN |
| Custom domains | Trivial | Possible but configured per site |
| Branding flexibility | Total control | Mostly total, some constraints |
| Operational complexity | Two systems to deploy | One system |
For a brand-critical public site where milliseconds matter, self-host. For an internal partner portal where you want to ship fast and not run infrastructure, Experience Cloud + React on the platform is the right answer.
The patterns in this guide apply to both. The OAuth flow changes (Experience Cloud handles auth differently), but the data access patterns, sharing rule enforcement, and API design are identical.
The Architectural Payoff
Look at what you have at the end of this build:
- A React application that ships independently of Salesforce, deployable in seconds.
- Full control over the user experience — animations, layouts, brand, performance.
- Salesforce’s sharing rules, validation rules, and automation engine doing the heavy lifting on the backend.
- An API contract that other clients (mobile app, AI agent, partner integration) can reuse.
- A clean separation of concerns: presentation in React, governance in Salesforce.
That is the entire promise of “Salesforce as backend.” Not “abandon Salesforce.” Not “Lightning is dead.” Just: use the right tool for each layer. Salesforce is exceptional at enterprise data governance and business logic. React is exceptional at user interfaces. Combine them on a clean API boundary, and you get the best of both.
The same pattern works for mobile apps (React Native against the same APIs), AI agents (calling the same endpoints), and partner integrations (your contract is now public-by-design). One backend. Many heads. Each head optimized for its audience.
Common Pitfalls to Avoid
A few things that will bite you, learned the hard way:
-
Do not embed the Consumer Secret in the frontend. PKCE is there specifically so you do not have to. If you find yourself wanting the secret on the client, you are using the wrong OAuth flow.
-
Do not skip sharing rule enforcement. It is tempting to make everything Public Read/Write and “filter on the client.” This will leak data the moment someone opens DevTools. Use real sharing rules.
-
Do not poll the API every five seconds. You will exhaust your daily API limits before lunch. Use Platform Events or Streaming API for real-time updates.
-
Do not hardcode the API version. Use
v62.0(or whatever is current) in your.env, and bump it deliberately as part of an upgrade plan. Salesforce maintains backward compatibility across versions, but new fields and features require new versions. -
Do not put business logic in React. If it has to be enforced consistently across UI, API, and AI agent — it lives in Salesforce (Apex, Flow, Validation Rules). The React app is for presentation only.
-
Do not forget to log out properly. Call Salesforce’s revoke endpoint when the user signs out, otherwise the tokens stay valid until they naturally expire.
Open Questions
The architecture above works for the customer portal scenario. But it raises questions worth wrestling with.
-
How do you handle offline? A React app on the open web can lose connectivity. Salesforce expects every write to be authoritative. Optimistic UI + retry queues get you most of the way, but conflict resolution is hard.
-
How do you scale to millions of users? Daily API limits are per-org. At some point, you outgrow the standard limits and need either a high-volume B2C license, Data Cloud, or a cache layer in between.
-
How do you handle Salesforce upgrades? Three releases a year. Sometimes APIs change behavior. Your contract tests need to run on every Salesforce sandbox refresh.
-
Where does AI fit? If an AI agent is calling the same APIs as your React app, are they two clients or one architecture? In 2026, the answer is increasingly “one architecture with two access patterns.” But that requires API design discipline that most teams have not yet developed.
Further Reading
If you want to go deeper on the foundations this guide builds on:
- Salesforce Just Broke Up With Its Own UI — The strategic context for why headless matters in 2026.
- Authentication Flows, Integration Patterns, and ESBs — The deep dive on OAuth, JWT, and integration plumbing.
- User Authentication and SSO in Salesforce — SSO, MFA, and Experience Cloud auth specifics.
- Salesforce Security for Architects — Sharing rules, FLS, and the governance layer.
- Agentforce for Developers — How the same APIs power AI agents alongside React frontends.
- An Introduction to Salesforce Architecture — Start here if the platform layers are unfamiliar.
The code in this guide is meant to be a starting point, not a copy-paste production system. Take the patterns, adapt them to your context, and ship something real. That is the only way to actually internalize how headless Salesforce works in 2026.