Frappe frontend
Skill prilk-consulting/frappe-agent-kit/skills/frappe-frontend
Production-grade Frappe Framework & ERPNext skills, agents, and scaffolding commands for Claude Code
npx -y skills add prilk-consulting/frappe-agent-kit --skill frappe-frontendAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Modern SPA frontends for Frappe apps. Use for React with frappe-react-sdk, Vue 3 with frappe-ui, frappe-js-sdk, Vite build configuration, the dev/prod boot dance, Socket.io realtime, and asset-path pitfalls that 404 in production. Not for desk form scripts (use frappe-dev) or server-rendered portal pages (use frappe-portal).
SKILL.md
8.0 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it
Modern Frontend Development for Frappe
Usage
Use this skill when:
- Building a React or Vue SPA inside a Frappe app (Raven/CRM-style)
- Wiring Vite builds into
bench buildand Frappe routing - Debugging prod asset 404s, dev boot failures, or silent realtime listeners
- Choosing between frappe-react-sdk, frappe-ui, and frappe-js-sdk
Stack options
| Stack | Libraries | Reference apps |
|---|---|---|
| React | frappe-react-sdk, SWR, Tailwind, Radix/shadcn | Raven |
| Vue 3 | frappe-ui, Pinia, Tailwind | CRM, Helpdesk |
frappe-js-sdk (any framework)
import { FrappeApp } from 'frappe-js-sdk'
const frappe = new FrappeApp('https://site.example.com')
await frappe.auth.loginWithUsernamePassword({ username, password })
await frappe.db.getDoc('Customer', 'X')
await frappe.db.getDocList('Customer', { fields: ['name'], filters: [['disabled', '=', 0]], limit: 20 })
await frappe.call.post('module.method', { param: 'value' })
await frappe.file.uploadFile(file, { doctype: 'Customer', docname: 'X' })
frappe-react-sdk (React)
<FrappeProvider socketPort={import.meta.env.VITE_SOCKET_PORT || '9000'}>
<App />
</FrappeProvider>
// SWR-backed hooks — call mutate() to refetch after writes
const { data, error, isLoading, mutate } = useFrappeGetCall<{ message: Row[] }>(
'myapp.api.get_list', { status: 'Open', limit: 50 })
const rows = data?.message ?? []
const { data: doc, mutate } = useFrappeGetDoc<Shape>(doctype, name)
const { data: list } = useFrappeGetDocList<Shape>('Customer', {
fields: ['name'], filters: [['disabled', '=', 0]],
orderBy: { field: 'modified', order: 'desc' }, limit: 50 })
const { updateDoc } = useFrappeUpdateDoc()
await updateDoc('Customer', name, { territory: 'US' }); await mutate()
useFrappeEventListener('myapp_progress', d => setProgress(d.percent))
useFrappeDocumentEventListener(doctype, name, () => mutate())
Error helper — every server error has { exception, exc_type, _server_messages, message }:
const msg = err?.exception || err?.message || 'Unexpected error'
React app scaffold (canonical)
apps/myapp/frontend/
├── index.html # Jinja: {{ csrf_token }}, {{ boot }}
├── package.json # dev / build / copy-html-entry scripts
├── vite.config.ts # base, proxyOptions, manualChunks
├── proxyOptions.ts # reads common_site_config.json
└── src/
├── main.tsx # dev/prod boot dance + FrappeProvider mount
├── App.tsx # BrowserRouter basename="/myroute"
└── components/ui/ # shadcn/ui wrappers (Radix + tailwind-merge)
The dev/prod boot dance
In prod, Frappe injects boot into window.frappe.boot via the Jinja template. In dev, the template literally contains {{ boot }} — you must fetch it before mounting:
<!-- index.html -->
<script>
window.csrf_token = '{{ csrf_token }}'
if (!window.frappe) window.frappe = {}
frappe.boot = JSON.parse({{ boot }})
</script>
// src/main.tsx
const mount = () => createRoot(document.getElementById('root')!).render(
<FrappeProvider socketPort={import.meta.env.VITE_SOCKET_PORT || '9000'}>
<App />
</FrappeProvider>)
if (import.meta.env.DEV) {
fetch('/api/method/myapp.www.myroute.get_context_for_dev', { method: 'POST' })
.then(r => r.json()).then(v => { window.frappe.boot = JSON.parse(v.message); mount() })
.catch(() => { window.location.href = '/login?redirect-to=/myroute' })
} else { mount() }
# myapp/www/myroute.py
@frappe.whitelist()
def get_context_for_dev():
return json.dumps(frappe.sessions.get())
Build chain — three paths MUST agree
--base=/assets/<app>/<name>/ in the build script · BrowserRouter basename="/<route>" · website_route_rules from_route. Any drift → assets 404 in prod.
// package.json
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/myapp/myname/ && yarn copy-html-entry",
"copy-html-entry": "cp ../myapp/public/myname/index.html ../myapp/www/myroute.html"
}
# hooks.py
website_route_rules = [{"from_route": "/myroute/<path:app_path>", "to_route": "myroute"}]
add_to_apps_screen = [{"name": "myapp", "logo": "/assets/myapp/images/logo.svg",
"title": "My App", "route": "/myroute"}]
The <path:app_path> catch-all gives client-side routing the whole subtree.
Vite config (canonical)
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: { port: 8080, proxy: proxyOptions },
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
build: {
outDir: '../myapp/public/myname',
emptyOutDir: true, target: 'es2015',
rollupOptions: { output: { manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
frappe: ['frappe-react-sdk'],
}}},
},
})
// proxyOptions.ts — derives upstream port from bench config; multi-site safe
const config = JSON.parse(fs.readFileSync('../../../sites/common_site_config.json', 'utf-8'))
const port = config.webserver_port || 8000
export default {
'^/(app|api|assets|files|private)': {
target: `http://127.0.0.1:${port}`,
ws: true, changeOrigin: true,
router: (req: any) => `http://${req.headers.host?.split(':')[0]}:${port}`,
},
}
frappe-ui (Vue 3)
import frappeui from 'frappe-ui/vite'
export default defineConfig({
plugins: [
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
buildConfig: { indexHtmlPath: '../myapp/www/app.html', emptyOutDir: true },
}),
vue(),
],
})
import { FrappeUI, setConfig, frappeRequest } from 'frappe-ui'
setConfig('resourceFetcher', frappeRequest)
app.use(FrappeUI).use(pinia)
const data = await frappeRequest({ method: 'frappe.client.get_list', body: { doctype, fields: ['name'] } })
Wiring the build into bench
Add a root package.json build script so bench build regenerates the bundle, then gitignore the build output (public/<bundle>/ and www/<route>.html):
// apps/myapp/package.json (root)
{ "scripts": { "build": "cd frontend && yarn install && yarn build" } }
Only commit built assets when the app has no root build wiring (and then document why).
Frontend gotchas
- Base path mismatch —
--base/basename/website_route_rulesdrift →/assets/*.js404 in prod. - Dev boot timing — In dev,
window.frappe.bootis undefined until theget_context_for_devfetch resolves; mount AFTER it resolves. - Socket port mismatch —
FrappeProvider socketPortmust match the bench'ssocketio_port. Mismatch fails silently — handlers never fire. - Proxy site routing —
proxyOptions.tsmust derive the host from request headers. Hardcodinglocalhost:8000breaks multi-site dev. copy-html-entryis mandatory — Vite writes topublic/; Frappe serves the entry fromwww/<route>.html. Skip the copy → route 404 after build.mutate()after writes, not refetch hooks — the SDK is SWR-backed; callmutate()to revalidate one query,globalMutate(matcher)to invalidate many.- Realtime needs
user=—frappe.publish_realtime(event, data, user=target)to target one user;useFrappeEventListeneronly fires for events bound tofrappe.session.user. - CSRF token —
frappe-react-sdkreadswindow.csrf_tokenautomatically. Don't pass it manually. - Validation stays server-side — SPA-side checks are UX hints; every rule must also exist in the DocType controller, because the REST API bypasses your React forms entirely.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.