Studio apps contain Studio Page documents with a JSON block tree, optional
Studio Page Resource rows, and page logic. Read RULES.md before
writing bindings, events, or styles. Read DESIGN.md before building
or restyling a page. For available components, check
frontend/src/data/components.ts and frontend/src/data/componentFamilies.ts.
Read ROUTING.md for navigation, redirects, guards or boot data.
Standard vs custom apps
| Mode | Where edits live | Page script | Local workflow |
|---|---|---|---|
| Custom | Studio documents in the site database | Bare body in Studio Page.script |
Edit and preview in Studio |
| Standard | Exported files under a linked Frappe app, synced to Studio documents | Companion .ts module with setup(context) |
Edit files, run watch-studio, check /dev/<app_route> |
Use the mode already set on the Studio App. The script forms and editing
workflows are not interchangeable.
Establish the app
-
For a standard (exported) app, create the app and pages in Studio, then enable export with
StudioApp.enable_app_export(target_app). This setsis_standardandfrappe_app, writes JSON, and moves existing page scripts into.tsfiles. Document save exports only in developer mode; seestudio/export.py:can_export. -
Files live under the linked Frappe app. Inspect an existing export such as
frappe/studio/note/before creating them directly:<frappe_app>/studio/<studio_app>/<studio_app>.json <frappe_app>/studio/<studio_app>/studio_page/<scrubbed_title>/<scrubbed_title>.json <frappe_app>/studio/<studio_app>/studio_page/<scrubbed_title>/<scrubbed_title>.ts <frappe_app>/studio/<studio_app>/studio_components/<component_name>.json -
The
.tsfile is optional. In page JSON, top-levelnameis the scrubbed title;page_nameis the real document ID used by Studio and the script bundle. Keeppage_namestable. If the title changes on disk, move the JSON and.tsfile to the newfrappe.scrub(page_title)stem. Resource fields such asfieldsandfiltersare JSON encoded strings in exported child rows. -
The app
routeis the URL prefix; page routes start with/and are relative to it.app_homeholds a page document ID. Public routes require published pages; guest access also requiresallow_guest.
Build the page
draft_blocksholds the editor's JSON array with one root block;blocksholds the published copy. Every root/container needsoriginalElement("body"for the root,"div"for containers) or it and its children will render blank. Component blocks must not haveoriginalElement.- Stored JSON uses
componentName,componentProps,baseStyles/mobileStyles/tabletStyles,componentSlots,children,componentEvents, and uniquecomponentIdvalues. AI tools use compact aliases such asname,props,style, andc; use an existing export as the model when writing JSON files directly. - Resources live in
Studio Page.resources. Read the actual DocType fields before configuring a Document List or Document source. A list exposes{{ source.data }}; a document exposes{{ source.doc }}. Repeater children seedataItem. Filters accept{"status": "Open"}or{"status": ["!=", "Closed"]}; multiple values need["in", [...]], not a bare list.
Page code and components
-
A custom (database-only) page uses a bare script body in
Studio Page.script: no imports or export. Page resources and variables are already in scope; variables are refs. -
A standard page uses a sibling
.tsmodule withexport default function setup(context) { ... }. Import Vue and Frappe UI APIs explicitly.contextsupplies page resources, variables,route,router,call,toastandsocket(the app's socket.io connection). Return every state value or handler used by bindings and events; keep state in code rather than Studio variables. Never mix the two script forms. -
Shared code for a standard app belongs under its Studio app folder and can be imported via
@app/.... -
Tailwind only scans files inside the
studio/app folder. If the app imports code from outside that folder (for example ashared/package that the Frappe app's frontend also uses), Tailwind classes used in that code will not be in the built CSS. To fix this, add atailwind.config.jsnext totsconfig.jsonand list the extra paths incontent. Paths are relative to the config file.node_modulesis skipped automatically. Onlycontent(an array of globs) andsafelistare used;themeandpluginsare ignored. The editor combines every app's config into one stylesheet, so keepsafelistshort. Restart the Vite dev server after adding or changing the file.export default { content: ["../../shared/src/**/*.{vue,ts}"], safelist: ["prose-sm"], }
Custom Vue components
Standard apps can use their own Vue components as blocks. Reach for one when
built-in components and block compositions can't express the UI cleanly: a
stateful widget (rating input, drag-and-drop board, canvas or SVG drawing), a
third-party library, or markup a page would otherwise rebuild from dozens of
blocks with heavy script glue. Don't use one to restyle something a built-in
component already does. For reusable layouts made of ordinary blocks, use a
Studio Component (studio_components/*.json) instead. Custom (database-only)
apps can't use .vue files.
-
Put a single-file component at
<frappe_app>/studio/<studio_app>/components/<ComponentName>.vue. Studio finds any.vuefile under the Studio app folder, and the file name is the block'scomponentName. -
Write it like any vue single-file component. Style with Tailwind classes and espresso tokens per DESIGN.md, and try to keep one root element (or bind
$attrsonto it) so block styles apply. -
Reference it from a block with
isCustomVueComponent: trueand nooriginalElement. Props,{{ }}bindings,{"$type": "variable"}v-model binds,componentEventsfor its emits, andcomponentSlotsfor its named slots all work as they do for built-in components:{ "componentId": "RatingInput-ab12cd", "componentName": "RatingInput", "isCustomVueComponent": true, "componentProps": {"max": 5, "modelValue": {"$type": "variable", "name": "rating"}}, "componentEvents": { "change": {"action": "Run Script", "event": "change", "script": "saveRating()"} } } -
With the Vite dev server running, adding, removing, or editing a
.vuefile updates the editor's component panel and canvas without a restart. A page.tsscript may also import it directly (import Board from "@app/components/Board.vue") to render it withh().
Sync, run, and verify
- For local standard app editing, run Studio's Vite dev server and
bench --site <site> watch-studio. The watcher imports changed app, page, and component JSON automatically. It discoversstudio/folders at startup, so restart it if the target folder was created later. Vite hot-reloads page.tsfiles; the watcher does not sync deletions. - Inspect the Vite-served
/dev/<app_route>preview and its page routes. Preview can show draft blocks without publishing or building. Do not runbench migrateorbuild-studio-appin the edit-and-preview loop. Build only when production assets or the built route need verification. - A Studio save can export a standard document back to disk. Let disk changes sync before editing that document in Studio; if the editor reports an external change, refresh before saving.
Working across an app
Keep sibling pages visually consistent: read an existing page's tree before
building a new one and reuse its palette, spacing rhythm, and section
structure. Routes start with / and are unique within the app. Build one
page at a time, completely, before starting the next; wire navigation on the
page that links to a new page, not just the new page itself.