Skip to main content

Frontend Customization Guide

This guide explains how to work with Framework M's bundled Desk UI and customize it for your needs.


Overview

Framework M ships with a pre-built Desk UI (similar to Frappe's Desk), providing a batteries-included experience. You can:

  1. Use the bundled UI - Zero npm setup required
  2. Customize the bundled UI - Run Vite dev server for live development
  3. Create custom frontends - Scaffold new UIs from templates

Quick Start: Bundled Desk UI

No npm Required

The bundled Desk UI is included in the Python package. Just install and run:

# Install Framework M
pip install framework-m

# Start the application in production mode
m prod

That's it! Open http://127.0.0.1:8000 in your browser. The Desk UI is pre-built and served automatically from the inner package assets.

How It Works

  • Pre-built static files are bundled in the PyPI framework-m-standard package
  • Served automatically by the backend when no custom frontend is active
  • No build step needed - zero-configuration setup for immediate use

Development Mode: Live Customization

When you need to customize the UI or build your own plugins, use m dev. This command is orchestration-heavy and requires Framework M Studio to be installed in your environment.

Starting with Frontend Dev Server

m dev

By default, m dev:

  1. Auto-detects if a frontend/ directory exists.
  2. Starts the Backend (uvicorn) with auto-reload.
  3. Starts the Frontend (Vite) concurrently with HMR (Hot Module Replacement).
  4. Starts Studio on port 9999 for visual editing.

Development Workflow

# Start the full dev stack
m dev

# Backend typically runs on http://127.0.0.1:8000
# Frontend runs on http://localhost:5173
# Studio runs on http://localhost:9999

# Edit files in frontend/src/ or use Studio to scaffold
# Changes appear instantly!

File-Based UI Overrides (Zero-Cliff)

Framework M supports a "Zero-Cliff" customization pattern. You don't need to rebuild the entire UI just to change one form or list. By simply creating files in specific directories, the framework automatically intercepts standard routes and renders your custom components, utilizing a Last-In-Wins priority overriding system.

Customizing a DocType Layout

To completely override or wrap the standard form for a Todo DocType, create a file at frontend/src/overrides/Todo/FormView.tsx:

// frontend/src/overrides/Todo/FormView.tsx
import { YStack, Card, Heading, MText } from "@framework-m/ui";
import type { FormViewProps } from "@framework-m/desk";

export default function CustomTodoFormView({ doctype, id, children }: FormViewProps) {
return (
<YStack padding="$lg" gap="$md" flex={1}>
{/* 1. Add your custom bespoke UI */}
<Card elevated padding="$md" backgroundColor="rgba(24, 144, 255, 0.1)" borderColor="$primary" borderWidth={1}>
<Heading size="$sm" color="$primary">🚀 Custom Todo Override</Heading>
<MText>Intercepting the standard form for: {id || "New Record"}</MText>
</Card>

{/* 2. Render the standard framework form underneath! */}
{children}
</YStack>
);
}

The @framework-m/vite-plugin automatically discovers this file and routes users to your custom view whenever they navigate to /app/Todo/new or /app/Todo/detail/:id.

Advanced Layout Overrides

Beyond FormView.tsx, you can override other standard layouts by creating appropriately named files in the src/overrides/[DocType]/ directory:

  • ListView.tsx
  • KanbanView.tsx
  • TreeView.tsx
  • CalendarView.tsx
  • GanttView.tsx

For non-DocType global page overrides (e.g., replacing the login page), place them in src/overrides/Core/:

  • src/overrides/Core/LoginPage.tsx

Expanding the Toolbox (The Field Registry)

While Form Overrides replace the entire page for a specific DocType, the Field Registry allows you to add new reusable tools to the global system. This is the preferred method for adding specialized inputs (e.g., Signature Pad, Barcode Scanner, Map Location) that should be available to any DocType via metadata.

Why not just override the form?

StrategyPropertyUse Case
Field ExtensionLightweight / SystemicGeneral widgets (Signature, Rating, Color)
Form OverrideHeavyweight / BespokeEntirely custom UX (Point of Sale, Dashboard)
LeverageHigh (Build once, use in all DocTypes)Low (Specific to one DocType)

Registering a Custom Field

To add a new field type, create your React component and register it with the FieldRegistry.

  1. Create the Component:
// frontend/src/components/fields/SignatureField.tsx
import { YStack, MText } from "@framework-m/ui";

export function SignatureField({ label, value, onChange }: any) {
return (
<YStack gap="$2" padding="$2" borderStyle="dashed" borderWidth={1} borderColor="$borderColor">
<MText size="sm" fontWeight="600">{label}</MText>
{/* Imagine a canvas signature pad here */}
<MText color="$colorMuted" size="xs">Signature Pad Placeholder</MText>
</YStack>
);
}
  1. Register it (Global Entry Point):

Usually this is done in your src/index.tsx or a dedicated plugin initialization file.

// frontend/src/index.tsx
import { FieldRegistry } from "@framework-m/desk";
import { SignatureField } from "./components/fields/SignatureField";

// Register the widget name "signature"
FieldRegistry.register("signature", SignatureField);

Using it in Backend Metadata

Once registered, you can use the field in any DocType by setting the ui_widget (or x-fieldtype) in the Python Meta class.

# backend/models/contract.py
class Contract(BaseDocType):
signature: str = Field(description="Client Signature")

class Meta:
ui_meta = {
"form": {
"sections": [
{
"fields": [
{"name": "signature", "ui_widget": "signature"}
]
}
]
}
}

The AutoField in the Desk UI will automatically resolve the "signature" key to your SignatureField component from the registry.


High-Density Spreadsheet UX (Child Tables)

For complex data entry like Sales Invoice Items or Task Lists, Framework M provides a Spreadsheet UX powered by the ListField component. This system is designed for high-velocity entry while maintaining deep property access.

Dual-Resolution Pattern

Framework M implements a tiered editing strategy for child tables:

  1. Selective Grid (The Fast Path): The main spreadsheet view only renders properties defined in grid_columns (or a smart fallback). This maintains high density and focus for rapid row entry.
  2. Exhaustive Detail Modal (The Source of Truth): Clicking the edit icon (🖉) opens a centered Detail Modal. This modal renders every property defined in the child table schema, allowing access to hidden metadata, settings, or secondary fields that would clutter the grid.

Universal Portals (Cross-Platform Parity)

The Edit Modal uses Universal Portals to ensure stability:

  • Global Projection: Modals are projected out of the grid's overflow: scroll containers to the document root, definitively preventing clipping and layout shifts.
  • Web & Native Parity: The system uses ReactDOM.createPortal on Web and a state-based host on React Native, ensuring your child table detail views work seamlessly across all platforms.
  • Theme Sync: Portals automatically synchronize with the application theme scope, ensuring colors and CSS variables are always correct.

Keyboard Power Productivity

The spreadsheet is optimized for keyboard-first navigation:

  • Ctrl + Enter: Global shortcut to instantly insert and focus a new row.
  • Natural Tab Flow: Moving between cells, actions, and the "Add Button" follows a logical sequence to prevent focus trapping.
  • Space/Enter: Standardized interactions for checkboxes and buttons within the high-density grid.

Configuring in Backend Metadata

You can control this UX directly from your Python models:

class Task(BaseDocType):
items: list[TaskItem] = Field(description="Sub-tasks")

class Meta:
ui_meta = {
"grid_columns": ["title", "assigned_to", "is_done"]
}

In this example, only those three fields appear in the high-density spreadsheet, but all fields of TaskItem will be editable in the Portal-based Detail Modal.


Adding Sidebar Menus

To add or modify sidebar navigation menus, export your menu definition from src/menu.ts. The Vite plugin automatically discovers this file and aggregates its contents into the shell's navigation tree.

// frontend/src/menu.ts
export const menu = [
{
label: "WMS",
icon: "warehouse",
children: [
{ label: "Warehouses", to: "/app/Warehouse" },
{ label: "Inventory", to: "/app/inventory-dashboard" }
]
}
];

Bypassing Standard Routing (Traversals)

Sometimes you want clicking a record or a menu item to take the user to a custom dashboard widget instead of the standard Form or List views. You can configure custom routing by exporting route resolver functions from src/overrides/traversals.ts. The shell intercepts navigation events and respects your custom trajectory.

// frontend/src/overrides/traversals.ts

export function resolveDetailRoute(doctype: string, id: string): string | null {
if (doctype === "Project") {
// Navigate to a bespoke project dashboard instead of the standard form
return `/app/Project/dashboard/${id}`;
}
return null; // Fallback to standard routing ('/app/{doctype}/detail/{id}')
}

export function resolveListRoute(doctype: string): string | null {
if (doctype === "Project") {
// Navigate to a custom project portfolio view instead of the standard generic list
return `/app/Project/portfolio`;
}
return null; // Fallback to standard routing ('/app/{doctype}/list')
}

Creating Custom Frontends

Initialize Frontend Template (Requires Studio)

Scaffolding new frontends is a developer-only feature provided by the m studio toolkit. You cannot scaffold in a production environment as the templates and logic are not installed there.

# Initialize in ./frontend using studio CLI
m studio new frontend

# Or specify custom location
m studio new frontend ../my-custom-ui

This scaffolds a complete React + TypeScript + Vite application with:

  • @framework-m/desk npm package (providers pre-configured)
  • Refine.dev setup for rapid admin UI development
  • React Router for client-side routing
  • Tailwind CSS + shadcn/ui components
  • Vite for fast development and optimized builds

Template Structure

frontend/
├── package.json # Dependencies and scripts
├── .npmrc # GitLab registry configuration
├── vite.config.ts # Vite build configuration
├── tsconfig.json # TypeScript configuration
├── src/
│ ├── index.tsx # Application entry point
│ ├── App.tsx # Refine.dev setup
│ ├── pages/ # Page components
│ │ ├── Dashboard.tsx
│ │ └── Login.tsx
│ └── components/ # Reusable components
├── public/ # Static assets
└── README.md # Setup instructions

Install Dependencies

After scaffolding, install dependencies:

cd frontend
pnpm install

Run Development Server

# Option 1: Run frontend only
cd frontend
pnpm dev

# Option 2: Run both backend + frontend (recommended)
cd ..
m prod --with-frontend

Using @framework-m/desk Package

What is @framework-m/desk?

The @framework-m/desk npm package provides pre-built providers for Refine.dev:

  • frameworkMDataProvider - CRUD operations via REST API
  • authProvider - Cookie-based authentication
  • liveProvider - WebSocket real-time updates
  • Constants - API URLs configured from environment

This package is automatically included in templates created with m new frontend .

Basic Usage

import { Refine } from "@refinedev/core";
import { BrowserRouter } from "react-router-dom";
import {
frameworkMDataProvider,
authProvider,
liveProvider,
} from "@framework-m/desk";

export default function App() {
return (
<BrowserRouter>
<Refine
dataProvider={frameworkMDataProvider}
authProvider={authProvider}
liveProvider={liveProvider}
resources={[
{
name: "User",
list: "/users",
show: "/users/:id",
edit: "/users/:id/edit",
create: "/users/new",
},
]}
>
{/* Your application routes */}
</Refine>
</BrowserRouter>
);
}

API Configuration

The package uses globalThis.__FRAMEWORK_CONFIG__ for API URLs:

// Set in index.html or environment
declare global {
interface Window {
__FRAMEWORK_CONFIG__?: {
API_URL?: string;
META_URL?: string;
WS_URL?: string;
};
}
}

// Default values (from vite.config.ts proxy)
globalThis.__FRAMEWORK_CONFIG__ = {
API_URL: "http://127.0.0.1:8888/api/v1",
META_URL: "http://127.0.0.1:8888/api/v1/meta",
WS_URL: "ws://127.0.0.1:8888/api/v1/live",
};

Authentication Flow

The authProvider handles cookie-based authentication:

// Login
await authProvider.login({
email: "admin@example.com",
password: "password",
});

// Check authentication status
const { authenticated } = await authProvider.check();

// Get current user identity
const identity = await authProvider.getIdentity();

// Logout
await authProvider.logout();

Real-time Updates

The liveProvider enables WebSocket subscriptions:

import { liveProvider, closeAllConnections } from "@framework-m/desk";

// Subscribe to User changes
liveProvider.subscribe({
channel: "User",
types: ["created", "updated", "deleted"],
callback: event => {
console.log("User changed:", event);
},
});

// Cleanup on unmount
useEffect(() => {
return () => closeAllConnections();
}, []);

GitLab Registry Configuration

Why GitLab Registry?

The @framework-m/desk package is published to your project's GitLab Package Registry (private by default). This ensures:

  • Version control - Package versions tied to git tags
  • Private packages - Not exposed to public npm registry
  • CI/CD integration - Automatic publishing on releases

Configure Access

The template includes a .npmrc file:

@framework-m:registry=https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/

Local Development

For local development outside CI/CD, create a Personal Access Token:

  1. GitLab SettingsAccess Tokens
  2. Create token with read_api scope
  3. Configure npm:
# In your frontend directory
echo "//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${YOUR_TOKEN}" >> .npmrc

CI/CD Authentication

In GitLab CI/CD, authentication is automatic:

# .gitlab-ci.yml
install-frontend:
script:
- cd frontend
- echo "@framework-m:registry=https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/" > .npmrc
- echo "//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}" >> .npmrc
- pnpm install

The CI_JOB_TOKEN is automatically available in GitLab CI/CD pipelines.

Publishing Updates

To publish a new version of @framework-m/desk:

# Update version in package.json
cd libs/framework-m-desk
npm version patch # or minor, major

# Create git tag
git tag framework-m-desk@v0.1.1
git push origin framework-m-desk@v0.1.1

# GitLab CI will build and publish automatically

CLI Reference

m prod

Start Framework M in production mode.

m prod [OPTIONS]

Options:

  • --port PORT - Backend port (default: 8000)
  • --host HOST - Host to bind to (default: 0.0.0.0)
  • --with-frontend - Serve custom built assets from frontend/dist via backend.
  • --frontend-dir PATH - Frontend directory path (default: ./frontend)

Examples:

# Production mode - serve bundled assets
m prod

# Production mode - serve custom built assets from frontend/dist
m prod --with-frontend

# Custom port
m prod --port 3000

m dev (Development Only)

Standard full-stack development command. Starts backend, frontend (Vite), and Studio.

m dev [OPTIONS]

Options:

  • --port PORT - Backend port (default: 8000)
  • --studio-port PORT - Studio port (default: 9999)
  • --no-frontend - Disable the Vite frontend process.
  • --enable-worker - Start the Taskiq background worker concurrently.

m studio new frontend (Requires Studio)

Initialize a new frontend from the standard template.

m studio new frontend [TARGET]

Arguments:

  • TARGET - Target directory for frontend (default: ./frontend)

Examples:

# Initialize in ./frontend
m studio new frontend

# Initialize in custom directory
m studio new frontend ../my-ui

What It Does:

  1. Copies template from apps/studio/frontend
  2. Creates directory structure with all configuration files
  3. Runs pnpm install to install dependencies
  4. Provides instructions for next steps

Deployment

Building for Production

cd frontend
pnpm build

This creates optimized static files in frontend/dist/.

Serving Production Build

The production build can be served in two ways:

Option 1: Bundle with PyPI package (recommended)

Include the built frontend in your PyPI distribution:

# Build frontend
cd frontend
pnpm build

# Copy to package static directory
cp -r dist/* ../libs/framework-m/src/framework_m/static/

# Build Python package
cd ../libs/framework-m
uv build

# Deploy
pip install dist/framework_m-*.whl
m prod

Option 2: Separate static file server

Serve frontend/dist/ with nginx, Caddy, or any static file server. Configure CORS to allow API requests from your frontend domain.

Distributed UI & Macroservices

For large applications, you can decompose your UI into independent macroservices. See the Distributed UI Guide for more information on how to configure remote MFE discovery and transparent proxying.

Environment Variables

Configure API endpoints for production:

<!-- frontend/index.html -->
<script>
window.__FRAMEWORK_CONFIG__ = {
API_URL: "https://api.yourdomain.com/api/v1",
META_URL: "https://api.yourdomain.com/api/v1/meta",
WS_URL: "wss://api.yourdomain.com/api/v1/live",
};
</script>

Or use environment variables with Vite:

# .env.production
VITE_API_URL=https://api.yourdomain.com/api/v1
VITE_META_URL=https://api.yourdomain.com/api/v1/meta
VITE_WS_URL=wss://api.yourdomain.com/api/v1/live

Troubleshooting

Studio Features Not Found

Symptom: m studio new frontend or m dev fails with "Command not found".

Solution: Ensure framework-m-studio is installed in your development environment. This package is typically not included in production-lean images.

uv pip install framework-m-studio

npm Package Not Found

Symptom: npm ERR! 404 Not Found - GET https://gitlab.com/...

Solutions:

  1. Check .npmrc configuration:
    cat frontend/.npmrc
  2. Verify CI_PROJECT_ID is set correctly
  3. Create Personal Access Token for local development
  4. Check package registry permissions in GitLab

Port Already in Use

Symptom: Error: Address already in use

Solutions:

# Use different port
m prod --port 8001

# Kill process using port 8888
lsof -ti:8888 | xargs kill -9

WebSocket Connection Failed

Symptom: Live updates not working

Solutions:

  1. Check WS_URL in configuration
  2. Verify WebSocket endpoint is accessible
  3. Check firewall/proxy settings allow WebSocket connections
  4. Use secure WebSocket (wss://) in production with HTTPS

Build Failures

Symptom: pnpm build fails with errors

Solutions:

  1. Clear node_modules and reinstall:
    rm -rf node_modules pnpm-lock.yaml
    pnpm install
  2. Check Node.js version (requires Node 20+)
  3. Verify TypeScript errors:
    pnpm tsc --noEmit

Best Practices

Development Workflow

  1. Start with bundled UI - Verify backend works first
  2. Scaffold frontend - Only when customization is needed
  3. Use m prod --with-frontend - Simplifies dual-server management
  4. Hot reload is automatic - Backend and frontend reload on changes
  5. Commit often - Frontend changes tracked in git

Project Structure

my-project/
├── app.py # Backend application entry
├── frontend/ # Custom frontend (gitignored by default)
│ ├── src/
│ │ ├── App.tsx
│ │ └── pages/
│ └── package.json
├── models/ # Your DocTypes
├── controllers/ # Business logic
└── pyproject.toml # Python dependencies

Version Control

Recommended .gitignore:

# Frontend
frontend/node_modules/
frontend/dist/
frontend/.npmrc # Contains auth tokens

# Python
__pycache__/
*.pyc
.venv/
dist/
*.egg-info/

Commit frontend source:

  • ✅ Commit frontend/src/, frontend/package.json, frontend/vite.config.ts
  • ❌ Don't commit node_modules/, dist/, .npmrc with tokens

Performance Optimization

  1. Use production builds - pnpm build creates optimized bundles
  2. Enable gzip/brotli - Compress static assets
  3. Use CDN - Serve static files from CDN in production
  4. Lazy load routes - Split code by route for faster initial load
  5. Cache API responses - Use Refine's built-in query caching

Next Steps


Support