@frappe-next/core
v0.1.3
Published
Next.js App Router SDK for Frappe Framework — SSR, Server Actions, Middleware
Maintainers
Readme
@frappe-next/core
Next.js App Router SDK for Frappe Framework — SSR-first data fetching, Server Actions, Edge middleware, and browser utilities that eliminate client-side waterfalls when building on Frappe/ERPNext backends.
The Problem
Frappe's existing React SDK (frappe-react-sdk) was built for CSR. Every page load triggers a client-side session check, a CSRF token fetch, then the actual data fetch — three sequential round trips before anything renders. With Next.js App Router you can run all of that on the server, stream HTML to the browser immediately, and keep sensitive API credentials out of the client bundle entirely.
@frappe-next/core gives you typed, cache-aware server helpers, Server Actions backed by API key auth, Edge-compatible auth middleware, and thin browser utilities for the interactions that genuinely need the client.
Install
npm i @frappe-next/coreRequirements
| Peer dependency | Version |
|---|---|
| next | >= 15.0.0 |
| react | >= 19.0.0 |
| react-dom | >= 19.0.0 |
| Frappe Framework | v15 / v16 |
Environment Variables
| Variable | Required | Description |
|---|---|---|
| FRAPPE_INTERNAL_URL | Docker/prod | Internal container URL, e.g. http://frappe-backend:8000. Takes priority over FRAPPE_URL. |
| FRAPPE_URL | Optional | Public Frappe URL. Used when FRAPPE_INTERNAL_URL is not set. |
| FRAPPE_SITE_NAME | Recommended | Frappe site name forwarded as X-Frappe-Site-Name. Falls back to NEXT_PUBLIC_FRAPPE_SITE, then site1.localhost. |
| FRAPPE_API_KEY | Actions | API key from Frappe Desk → Settings → Users → API Access. |
| FRAPPE_API_SECRET | Actions | API secret paired with FRAPPE_API_KEY. |
| FRAPPE_REQUEST_TIMEOUT | Optional | Timeout in milliseconds for server-side Frappe requests. Default: 8000. |
| NEXT_PUBLIC_FRAPPE_SITE | Optional | Public site name for client-accessible config. |
URL resolution priority (server-side): FRAPPE_INTERNAL_URL → FRAPPE_URL → http://127.0.0.1:8000
Quick Start
1. Middleware — auth guard and session injection
Create src/middleware.ts:
import { createFrappeAuthMiddleware } from '@frappe-next/core/middleware'
export default createFrappeAuthMiddleware({
loginPath: '/login',
publicPaths: ['/about', '/pricing'],
})
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}The middleware verifies the Frappe session cookie on every protected request and injects the authenticated username as x-frappe-user into the downstream request headers. Server Components can read it without an extra Frappe call.
2. Root layout — boot data and provider
// src/app/layout.tsx
import { getFrappeBootData } from '@frappe-next/core/server'
import { FrappeNextProvider } from '@frappe-next/core/components'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const boot = await getFrappeBootData()
return (
<html>
<body>
<FrappeNextProvider {...boot}>
{children}
</FrappeNextProvider>
</body>
</html>
)
}getFrappeBootData() reads the user from the middleware-injected header and fetches the CSRF token — once per request, memoised with React.cache(). FrappeNextProvider makes the token available to client-side fetchers via window.csrf_token.
API Reference
@frappe-next/core/server
Server-only utilities for Server Components, generateMetadata, and generateStaticParams. The module enforces this boundary with import 'server-only'.
getDoc<T>(doctype, name, options?)
Fetches a single Frappe document. Deduplicated per request via React.cache() — ten Server Components calling getDoc('Item', 'ITEM-001') produce exactly one network request. Tagged for Next.js ISR cache invalidation.
import { getDoc } from '@frappe-next/core/server'
interface Item {
name: string
item_name: string
description: string
standard_rate: number
}
// In a Server Component:
const item = await getDoc<Item>('Item', 'ITEM-001')Custom ISR configuration:
const item = await getDoc<Item>('Item', 'ITEM-001', {
next: { revalidate: 60 }, // revalidate every 60 s
})getDocOrNull<T>(doctype, name, options?)
Same as getDoc but returns null on 404 instead of throwing. Use for optional documents without a try/catch block.
const draft = await getDocOrNull<SalesOrder>('Sales Order', params.name)
if (!draft) notFound()getList<T>(doctype, args?, options?)
Fetches a list of documents. Tagged with the doctype name for ISR invalidation.
import { getList } from '@frappe-next/core/server'
import type { FrappeFilter } from '@frappe-next/core/types'
const orders = await getList<SalesOrder>('Sales Order', {
fields: ['name', 'customer', 'grand_total', 'status'],
filters: [['status', '=', 'Submitted']],
order_by: 'creation desc',
limit: 50,
})GetListArgs options:
| Option | Type | Default |
|---|---|---|
| fields | string[] | ['name', 'modified'] |
| filters | FrappeFilter[] | [] |
| or_filters | FrappeFilter[] | [] |
| limit | number | 20 |
| limit_start | number | 0 |
| order_by | string | 'modified desc' |
getCount(doctype, filters?, options?)
Returns the document count matching the given filters.
const openCount = await getCount('Task', [['status', '=', 'Open']])frappeGet<T>(method, params?, options?)
Low-level GET wrapper for any whitelisted Frappe method. Use when getDoc/getList do not cover your endpoint.
import { frappeGet } from '@frappe-next/core/server'
const result = await frappeGet<{ items: string[] }>(
'myapp.api.get_dashboard_data',
{ warehouse: 'Main' },
{ next: { tags: ['dashboard'], revalidate: 30 } },
)frappePost<T>(method, body?, options?)
Low-level POST wrapper. Prefers API key authentication for server-to-server calls; falls back to forwarding the session cookie.
import { frappePost } from '@frappe-next/core/server'
await frappePost('myapp.api.process_batch', { batch_id: 'BATCH-001' })getFrappeBootData()
Returns { csrfToken, user, siteName }. Reads the authenticated user from the x-frappe-user header set by middleware and fetches the CSRF token (once per request via React.cache()).
import { getFrappeBootData } from '@frappe-next/core/server'
const { user, csrfToken, siteName } = await getFrappeBootData()revalidateDoc(doctype, name) / revalidateList(doctype)
Invalidate the Next.js ISR cache after mutations. Call from Server Actions alongside write operations.
import { revalidateDoc, revalidateList } from '@frappe-next/core/server'
revalidateDoc('Item', 'ITEM-001') // invalidates getDoc cache for this document
revalidateList('Item') // invalidates getList cache for ItemError Classes
All server helpers throw typed errors you can catch and handle specifically:
import {
FrappeApiError,
FrappeAuthError,
FrappeNotFoundError,
} from '@frappe-next/core/server'
try {
const doc = await getDoc<Item>('Item', name)
} catch (err) {
if (err instanceof FrappeNotFoundError) {
notFound() // Next.js 404 page
}
if (err instanceof FrappeAuthError) {
redirect('/login') // session expired
}
throw err // unexpected — let the error boundary handle it
}| Class | HTTP Status | When thrown |
|---|---|---|
| FrappeNotFoundError | 404 | Document or method not found |
| FrappeAuthError | 403 | Session expired or insufficient permissions |
| FrappeApiError | any | Base class — all other non-OK responses |
All three extend Error and carry .status, .method, and .details properties.
@frappe-next/core/actions
Helpers for use inside your own Server Actions. This module is server-only and uses API key authentication — no CSRF token required. Do not put 'use server' in this module; add it in your own actions.ts files.
// src/app/actions.ts
'use server'
import { createDoc, updateDoc, deleteDoc, callMethod } from '@frappe-next/core/actions'
import { revalidateDoc, revalidateList } from '@frappe-next/core/server'callMethod<T>(method, body?)
Calls any whitelisted Frappe API method.
const result = await callMethod<{ pdf_url: string }>(
'myapp.api.generate_pdf',
{ doctype: 'Sales Invoice', name: 'SINV-0001' },
)
if (!result.ok) {
console.error(result.error)
return
}
console.log(result.data.pdf_url)createDoc<T>(doctype, doc)
Creates a new document via POST /api/resource/{doctype}.
const result = await createDoc<Task>('Task', {
subject: 'Review proposal',
assigned_to: '[email protected]',
priority: 'High',
})
if (result.ok) {
revalidateList('Task')
return result.data.name // e.g. "TASK-00042"
}updateDoc<T>(doctype, name, updates)
Updates an existing document via PUT /api/resource/{doctype}/{name}.
const result = await updateDoc<Task>('Task', 'TASK-00042', {
status: 'Completed',
})
if (result.ok) {
revalidateDoc('Task', 'TASK-00042')
}deleteDoc(doctype, name)
Deletes a document via DELETE /api/resource/{doctype}/{name}.
const result = await deleteDoc('Task', 'TASK-00042')
if (result.ok) {
revalidateList('Task')
}All action helpers return ActionResult<T>, a discriminated union:
type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status?: number }@frappe-next/core/client
Browser-only utilities marked with 'use client'. Safe to import in Client Components.
frappeClientGet<T>(method, params?)
Fetches from a Frappe method using the browser's session cookie. Uses relative URLs so no CORS configuration is required.
'use client'
import { frappeClientGet } from '@frappe-next/core/client'
const suggestions = await frappeClientGet<string[]>(
'frappe.desk.search.search_link',
{ txt: query, doctype: 'Customer', ignore_user_permissions: 0 },
)frappeClientPost<T>(method, body?)
Posts to a Frappe method from the browser. Reads the CSRF token from window.csrf_token (injected by FrappeNextProvider) or falls back to the csrf_token cookie.
'use client'
import { frappeClientPost } from '@frappe-next/core/client'
const result = await frappeClientPost<{ message: string }>(
'myapp.api.submit_feedback',
{ rating: 5, comment: 'Great service' },
)frappeLogin(usr, pwd)
Authenticates against Frappe and returns the full login response including home_page for post-login redirect.
'use client'
import { frappeLogin } from '@frappe-next/core/client'
async function handleSubmit(usr: string, pwd: string) {
const res = await frappeLogin(usr, pwd)
// res.message === 'Logged In'
// res.home_page === '/dashboard' (or whatever Frappe returns)
window.location.href = res.home_page ?? '/'
}FrappeLoginResponse:
interface FrappeLoginResponse {
message: string // "Logged In" | "No App" | etc.
home_page?: string // post-login redirect target
full_name?: string
}useFrappeRouter()
A smart router hook that routes navigation to Next.js client-side navigation for your app's pages and falls back to window.location.href for Frappe-owned paths (/app, /api, /files, /print, etc.).
'use client'
import { useFrappeRouter } from '@frappe-next/core/client'
function Nav() {
const { navigate, toDesk, toDoc } = useFrappeRouter()
return (
<>
{/* SPA navigation — no page reload */}
<button onClick={() => navigate('/dashboard')}>Dashboard</button>
{/* Full navigation to Frappe Desk */}
<button onClick={() => toDesk()}>Open Desk</button>
<button onClick={() => toDesk('selling')}>Selling Module</button>
{/* Navigate to a specific Frappe document */}
<button onClick={() => toDoc('Sales Order', 'SO-00142')}>View Order</button>
</>
)
}Frappe-owned path prefixes: /app, /api, /assets, /files, /private, /me, /update-password, /print, /list, /form, /tree, /report, /dashboard.
@frappe-next/core/middleware
createFrappeAuthMiddleware(config?)
Factory that returns a Next.js-compatible middleware function. Runs at Edge Runtime — zero Node.js-only APIs.
// src/middleware.ts
import { createFrappeAuthMiddleware } from '@frappe-next/core/middleware'
export default createFrappeAuthMiddleware({
loginPath: '/login',
publicPaths: ['/about', '/pricing', '/api/webhook'],
sessionTimeoutMs: 4000,
})
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}FrappeMiddlewareConfig:
| Option | Type | Default | Description |
|---|---|---|---|
| frappeUrl | string | env fallback | Override Frappe URL. Defaults to FRAPPE_INTERNAL_URL → FRAPPE_URL → http://127.0.0.1:8000 |
| loginPath | string | '/login' | Path to redirect unauthenticated requests to |
| publicPaths | string[] | [] | Path prefixes that bypass session verification |
| sessionTimeoutMs | number | 4000 | Abort timeout for the Frappe session check |
What the middleware does on each request:
- Static assets (
/_next/,/favicon.ico,/robots.txt,/sitemap.xml) and paths inpublicPathspass through immediately — no Frappe call. - If no
sidcookie is present or it equals'Guest', the request is redirected tologinPath?next={original_path}&reason=no_session. - The
sidis verified againstfrappe.auth.get_logged_user. If the session is invalid, thesidcookie is cleared and the request is redirected. - On success, the authenticated username is injected as
x-frappe-userin the request headers for downstream Server Components to read.
@frappe-next/core/components
FrappeNextProvider
A Client Component that provides Frappe boot data (CSRF token, current user, site name) to the React tree and injects window.csrf_token for client-side fetchers.
// src/app/layout.tsx
import { getFrappeBootData } from '@frappe-next/core/server'
import { FrappeNextProvider } from '@frappe-next/core/components'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const boot = await getFrappeBootData()
return (
<html>
<body>
<FrappeNextProvider {...boot}>
{children}
</FrappeNextProvider>
</body>
</html>
)
}Props (BootData):
| Prop | Type | Description |
|---|---|---|
| csrfToken | string | Frappe CSRF token. Injected as window.csrf_token. |
| user | string \| null | Authenticated user email, or null for guests. |
| siteName | string | Frappe site name from FRAPPE_SITE_NAME. |
useFrappeNext()
Access boot data from any Client Component in the tree.
'use client'
import { useFrappeNext } from '@frappe-next/core/components'
function UserBadge() {
const { user, hydrated } = useFrappeNext()
if (!hydrated) return null
return <span>{user ?? 'Guest'}</span>
}Returns { csrfToken, user, siteName, hydrated } where hydrated is false during SSR and flips to true after the first client effect.
@frappe-next/core/types
Pure TypeScript interfaces — zero runtime cost.
import type {
FrappeFilter,
FrappeDoc,
GetListArgs,
FrappeFetchOptions,
FrappeEnvelope,
FrappeParams,
BootData,
ActionResult,
ActionOk,
ActionErr,
NextCacheConfig,
} from '@frappe-next/core/types'Key types:
// Frappe filter tuple: [field, operator, value]
type FrappeFilter = [string, string, unknown]
// Examples:
const filters: FrappeFilter[] = [
['status', '=', 'Submitted'],
['grand_total','>=', 1000],
['customer', 'in', ['CUST-001', 'CUST-002']],
['name', 'like', 'SO-%'],
]
// Base Frappe document fields (extend with your own)
interface FrappeDoc {
name: string
owner: string
creation: string
modified: string
modified_by: string
doctype: string
docstatus: 0 | 1 | 2 // draft | submitted | cancelled
idx: number
[key: string]: unknown
}
// ISR / cache control passed to fetch's `next` option
interface NextCacheConfig {
revalidate?: number | false
tags?: string[]
}
// Options accepted by frappeGet, getDoc, getList, etc.
interface FrappeFetchOptions {
next?: NextCacheConfig
headers?: Record<string, string>
skipSession?: boolean // omit cookie forwarding (useful for public data)
}
// Discriminated union returned by all action helpers
type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status?: number }Architecture Overview
Request
│
├── Edge Middleware (createFrappeAuthMiddleware)
│ Verifies sid cookie against Frappe
│ Injects x-frappe-user header
│
└── Next.js Server (Node.js runtime)
│
├── Server Components
│ getDoc / getList / getCount / frappeGet
│ React.cache() deduplication per request
│ Next.js ISR tags for cache invalidation
│
├── Server Actions (your actions.ts adds 'use server')
│ createDoc / updateDoc / deleteDoc / callMethod
│ API key auth — no CSRF, no cookie forwarding
│ revalidateDoc / revalidateList after mutations
│
└── Client Components
frappeClientGet / frappeClientPost
frappeLogin
useFrappeRouter
useFrappeNextISR Cache Invalidation Pattern
Server helpers automatically apply Next.js cache tags:
| Helper | Tags applied |
|---|---|
| getDoc(doctype, name) | ${doctype}::${name} |
| getList(doctype, ...) | ${doctype} |
After a mutation in a Server Action:
'use server'
import { updateDoc } from '@frappe-next/core/actions'
import { revalidateDoc, revalidateList } from '@frappe-next/core/server'
export async function submitOrder(name: string) {
const result = await updateDoc('Sales Order', name, { status: 'Submitted' })
if (result.ok) {
revalidateDoc('Sales Order', name) // bust this document's ISR cache
revalidateList('Sales Order') // bust any list pages showing this doctype
}
return result
}vs. frappe-react-sdk
| | frappe-react-sdk | @frappe-next/core |
|---|---|---|
| Architecture | CSR, SWR hooks | SSR-first, Server Components |
| Auth flow | Client-side session check on mount | Edge middleware — verified before render |
| Data fetching | Client waterfall (session → CSRF → data) | Server Component — one render, no waterfall |
| CSRF | Managed by client hooks | Injected by FrappeNextProvider, auto-read |
| Caching | SWR in-memory | Next.js ISR with tag-based invalidation |
| Bundle impact | Ships hooks and SWR to the client | Server code never ships to the browser |
| Mutations | Client-side with CSRF token | Server Actions with API key — no CSRF needed |
| App Router | Not supported | Native |
| TypeScript | Partial | Full |
License
MIT — see LICENSE.
