npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@frappe-next/core

v0.1.3

Published

Next.js App Router SDK for Frappe Framework — SSR, Server Actions, Middleware

Readme

@frappe-next/core

npm version license

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/core

Requirements

| 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_URLFRAPPE_URLhttp://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 Item

Error 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_URLFRAPPE_URLhttp://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:

  1. Static assets (/_next/, /favicon.ico, /robots.txt, /sitemap.xml) and paths in publicPaths pass through immediately — no Frappe call.
  2. If no sid cookie is present or it equals 'Guest', the request is redirected to loginPath?next={original_path}&reason=no_session.
  3. The sid is verified against frappe.auth.get_logged_user. If the session is invalid, the sid cookie is cleared and the request is redirected.
  4. On success, the authenticated username is injected as x-frappe-user in 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
              useFrappeNext

ISR 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.