About Experience Engineering Projects Infrastructure Blog Contact
ERPNext & Frappe 🧩 9-Part Course · Part 7: Custom App Development

Building a Custom Frappe App: From bench new-app to Production

Published: Aug 30, 2026

🧱 Why a Custom App, Not Core Edits

Part 1's best practices already state the rule once: never modify ERPNext (or Frappe) core source. This article is about actually living by that rule while still building something real. The reason is mechanical, not philosophical β€” apps/erpnext/ and apps/frappe/ are git checkouts that bench update pulls fresh code into. Any line you hand-edit there either gets silently overwritten on the next update, or causes a merge conflict that halts the update entirely. Either outcome destroys the one thing an ERP system depends on most: a safe, repeatable upgrade path. This is the same discipline as the "don't silently edit history" principle behind immutable audit trails in ERP accounting β€” a core file you quietly patched is functionally the same problem as a ledger entry quietly changed after the fact: nobody downstream can trust what they're looking at anymore.

The alternative is to treat ERPNext core as a fixed, versioned dependency and build everything domain-specific as a separate, installable app that talks to core only through its public surface β€” Link fields, hooks, and the REST API. That target architecture is a straight line:

Custom App
β†’
Hooks
β†’
Custom DocTypes
β†’
Custom Fields
β†’
Workflows
β†’
APIs
β†’
ERPNext Coreintegrates with, never edits

This is deliberately the same end state as Part 1's customization decision tree: "need new DocTypes, deep integration, or reusable logic β†’ build a Custom App." Everything below is what actually happens once you reach that end node.

πŸ—οΈ Scaffolding the App

Two bench commands take you from nothing to an installed, runnable custom app:

bench new-app my_custom_app
bench --site site1.local install-app my_custom_app

bench new-app generates the app's skeleton directory (see the next section) and registers its name in the bench's apps.txt, so the bench knows this app exists alongside frappe and erpnext. It does not touch any site's database. bench --site SITE install-app is the separate step that actually activates the app for one specific site: it runs the app's install hooks and applies any pending migrations (new DocType tables, patches) against that site's database. This mirrors the get-app vs install-app split covered in Part 1's installation section β€” fetching/creating an app's code is one action, enabling it for a given site is another, and one bench can have a custom app installed on some sites and not others.

πŸ“ App Directory Structure

my_custom_app/
β”œβ”€β”€ hooks.py
β”œβ”€β”€ modules.txt
β”œβ”€β”€ patches.txt
β”œβ”€β”€ public/
β”œβ”€β”€ templates/
└── my_custom_app/
    └── my_module/
        β”œβ”€β”€ doctype/
        β”œβ”€β”€ report/
        └── page/
EntryWhat it's for
hooks.pyApp configuration β€” doc_events, scheduler_events, includes, fixtures, and more. Arguably the single most important file in the whole app; it's the declarative wiring between your code and Frappe's lifecycle.
modules.txtDeclares which Module Def(s) this app owns β€” the grouping that makes the custom DocTypes show up under a named module in the Desk.
patches.txtLists data-migration patch scripts, run in order, whenever bench migrate runs against a site with this app installed.
public/JS, CSS, and image assets served to the browser.
templates/Jinja templates for web pages, emails, and print formats.
doctype/One folder per custom DocType, each holding its .json definition, .py controller, and .js client script.
report/Custom Query Reports and Script Reports.
page/Custom Desk pages and dashboards that aren't a standard DocType list/form.
⚠️ Layout can shift between Frappe versions. This is a conceptually stable, long-standing layout, but treat exact filenames and generated boilerplate as something to verify against the Frappe version you're actually running β€” the same caution Part 1's accuracy note applies to table names applies here to scaffold structure.

πŸͺ Hooks & Events, In Depth

Part 1 listed the hook categories briefly. Here's what each one is actually for:

HookPurposeExample use
doc_eventsReact to a DocType's lifecycle eventson_submit of Sales Invoice triggers custom logic
scheduler_eventsCron-like background jobsA nightly job emails overdue-invoice reminders
app_include_js / app_include_cssLoad custom JS/CSS globally across the DeskA shared UI helper available on every screen
fixturesExport specific records as JSON so they reinstall automatically on a fresh siteCustom Field, Role, and Workflow definitions shipped with the app
permission_query_conditionsInject an extra SQL WHERE-style condition into list queriesRow-level security beyond standard Role/User Permissions
has_permissionA Python function controlling document-level access programmaticallyCustom rule: only the assigned collector may edit a Lab Sample
override_doctype_classReplace a standard DocType's controller class with your own subclassAdd/override methods on Sales Invoice without touching its source file

The document-save lifecycle those doc_events hooks tap into is covered in full, including where accounting/stock side-effects fire, in Part 5's document lifecycle internals β€” rather than re-deriving that diagram here, the point to take from it is this: doc_events hooks are how your custom app taps into validate / before_save / on_update / after_save / on_submit / on_cancel for ANY DocType, including ERPNext's own, without editing that DocType's source file. This is the mechanism that makes "integrate, don't edit core" actually practical rather than just a rule on paper.

🧬 Creating a Custom DocType

To make the rest of this article concrete, walk through one real example: Lab Sample β€” the same DocType briefly foreshadowed in Part 1's Lab Management case study, and the one Part 9's capstone builds out fully.

FieldTypeNotes
sample_idDataSet via a naming series, e.g. LAB-SAMPLE-.YYYY.-.#####
patientLink β†’ PatientPatient is itself a custom DocType this same app defines
sample_typeSelectOptions like Blood / Urine / Tissue
collection_dateDate 
statusSelectDraft / Collected / In Lab / Reported
collectorLink β†’ EmployeeReuses ERPNext's own Employee master directly
testsTable β†’ Lab Sample TestChild DocType holding the individual tests ordered on this sample

"Lab Sample Test" is a child DocType: its "Is Child Table" flag is checked, and every row it stores carries the same four framework-managed fields β€” parent, parenttype, parentfield, idx β€” covered in full in Part 4's child tables deep-dive. Nothing about a custom app's child tables works differently from ERPNext's own; that's exactly the point β€” a custom DocType is a first-class citizen of the same framework, not a bolted-on approximation of one.

πŸ”— Linking a Custom DocType Into ERPNext Core

The technique that actually makes a custom app "integrate" rather than just "coexist" is ordinary Link fields pointed at core DocTypes:

Field on custom DocTypeLink targetWhy
Lab Sample.patientPatient (custom) β†’ patient.customer β†’ CustomerSo a lab bill can be raised through the standard Sales Invoice flow, against a real Customer record
Lab Sample.companyCompanyEvery billable/accounting-adjacent document needs a Company, same as core transactions
Lab Sample Test.itemItemEach lab test maps to a billable Item, so pricing and invoicing reuse ERPNext's existing Item/Price List machinery
Lab Sample.collectorEmployeeReuses the existing HR master instead of a custom "staff" list

Customer

Item

Company

Employee

↑referenced by (Link fields)

Patient

Sample

Test

ERPNext Core (top) Β· Custom App (bottom)

Every one of these is a plain foreign-key-style Link field, exactly as described in Part 1's DocType β†’ Table architecture. Nothing about linking into core requires touching core's schema β€” the custom DocType's own JSON definition simply names the target DocType, and the framework generates the column and the UI picker for you.

πŸͺœ Three Levels of Integration

Part 1's custom-module section introduced three integration levels in brief. Each one has a concrete action behind it:

LevelConcrete action
Level 1 β€” UIAdd a Workspace/module icon and dashboard shortcuts so Lab Sample, Patient, and the rest appear in the sidebar like a native module, not a hidden feature only power users can find
Level 2 β€” DataThe Link-field wiring from the previous section, plus a Dynamic Link wherever a custom DocType needs a polymorphic reference (e.g. an Address that could belong to either a Patient or a Supplier)
Level 3 β€” Business LogicA doc_events hook β€” e.g. on Lab Report's on_submit β€” that programmatically creates a Sales Invoice against the linked Customer and Item. This is the actual payoff of doing Level 2 wiring correctly: the business logic has real records to act on
Custom Module
↓
UI
↓
Database
↓
Business Logic
↓
Events
↓
API
↓
ERPNext Core

A custom app that only does Level 2 works technically but feels bolted-on; skipping Level 1 is common enough that it gets its own line in Common Custom App Mistakes below.

🚚 Versioning & Safe Deployment

A custom app is a git repository like any other, and it should be treated as one from the first commit. Moving customizations between dev, staging, and production should always go through fixtures and patches, never through manually editing the production database directly β€” the same discipline Part 1's best practices already lays out for the whole ERPNext stack. Every bench update or bench migrate should be run and verified in staging before it ever touches production.

Three commands cover the full lifecycle of applying a custom app to a site β€” the same ones from Part 1's cheat sheet, used together:

bench new-app my_custom_app                        # scaffold, once
bench --site SITE install-app my_custom_app        # activate on a site
bench --site SITE migrate                          # apply pending patches (every deploy)

Full depth on exposing this app through the API, packaging it for deployment, and the troubleshooting playbook for when a migration or update goes wrong is Part 8's subject β€” this section is deliberately just the checklist, not the mechanics.

🚧 Common Custom App Mistakes

MistakeWhy it hurts
Naming a custom DocType the same as (or too close to) a core ERPNext DocTypeCauses confusion for every developer after you, and risks a collision on a future ERPNext upgrade that introduces a similarly named core DocType
Putting business logic in Client Script when it belongs in a doc_events hookClient Script only runs in the browser β€” it's bypassable via the API, as covered in Part 6's customization guide; anything that must always happen belongs server-side
Forgetting to add fixtures for Custom Fields, Roles, or Workflows created via the UIThose changes live only in that site's database β€” they don't ship with the app, so a fresh install is silently missing them
Tightly coupling a custom DocType's controller to ERPNext internals that aren't part of a stable public APIBreaks quietly on the next ERPNext upgrade, for the same underlying reason core source edits break β€” you're depending on something not guaranteed to stay the same
Skipping Level 1 UI integration entirelyLeaves a technically-working module that users can't discover β€” see Three Levels of Integration above

πŸ—ΊοΈ What's Next

This article is Part 7 of the 9-part ERPNext course, going far deeper than the brief #custom-app and #custom-module sections in Part 1 β€” everything from directory structure to hook categories to the full Lab Sample integration example lives here instead. It's also the destination Part 1's customization ladder points to once you outgrow the no-code tools covered in Part 6.

From here: Part 8 covers exposing this app through the REST API and deploying it safely β€” the full version of the checklist in Versioning & Safe Deployment above. Part 9 builds the full Laboratory Management app using exactly this structure β€” the same Lab Sample DocType, the same Level 1/2/3 integration, taken from example to complete capstone project.

↑