Vite & TypeScript Compilation
Build Vue and TypeScript apps with Vite and serve the compiled bundles from Frappe.
On this page
Build Vue/TypeScript applications with Vite and integrate compiled assets into Frappe endpoints. This enables modern frontend tooling (hot module replacement, TypeScript, Vue SFCs) while producing bundles Frappe can serve.
Project Setup
Directory Structure
myapp/
├── package.json # Node dependencies + build scripts
├── tsconfig.json # TypeScript configuration
├── vite.config.ts # Vite build configuration
├── myapp/
│ ├── hooks.py # Asset registration
│ └── public/
│ ├── js/
│ │ ├── app_a/ # Source: App A
│ │ │ ├── app_a.ts # Entry point
│ │ │ ├── AppComponent.vue # Vue SFC
│ │ │ ├── app_a.css # Styles
│ │ │ └── components/ # Sub-components
│ │ └── app_b/ # Source: App B
│ │ └── app_b.ts
│ └── dist/
│ └── js/
│ ├── app_a/ # Build output: App A
│ │ ├── index.js
│ │ └── index.css
│ └── app_b/ # Build output: App B
│ ├── index.js
│ └── index.cssConvention: Source in public/js/<app>/ → Output in public/dist/js/<app>/.
package.json
{
"name": "myapp",
"private": true,
"scripts": {
"build": "VITE_APP=app_a vite build && VITE_APP=app_b vite build",
"build:app_a": "VITE_APP=app_a vite build",
"build:app_b": "VITE_APP=app_b vite build",
"dev:app_a": "VITE_APP=app_a vite build --watch",
"dev:app_b": "VITE_APP=app_b vite build --watch"
},
"dependencies": {
"vue": "^3.5",
"frappe-ui": "^0.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0",
"typescript": "^5.7",
"unplugin-icons": "^22.0",
"vite": "npm:rolldown-vite@latest"
}
}Each app target gets its own build and dev (watch) script. The VITE_APP environment variable selects the target.
tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"strict": false,
"noImplicitAny": false,
"jsx": "preserve",
"paths": {
"@/*": ["myapp/public/js/*"],
"@app_a/*": ["myapp/public/js/app_a/*"],
"@app_b/*": ["myapp/public/js/app_b/*"]
}
},
"include": [
"myapp/public/js/**/*.ts",
"myapp/public/js/**/*.d.ts",
"myapp/public/js/**/*.vue"
]
}vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Icons from "unplugin-icons/vite";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import type { Plugin, ResolvedConfig } from "vite";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const appName = process.env.VITE_APP || "app_a";
interface AppConfig {
entry: string;
outDir: string;
srcDir: string;
}
const appConfigs: Record<string, AppConfig> = {
app_a: {
entry: "app_a.ts",
outDir: "myapp/public/dist/js/app_a",
srcDir: "myapp/public/js/app_a",
},
app_b: {
entry: "app_b.ts",
outDir: "myapp/public/dist/js/app_b",
srcDir: "myapp/public/js/app_b",
},
};
const config = appConfigs[appName];
export default defineConfig({
plugins: [
vue(),
Icons({ compiler: "vue3", autoInstall: true }),
frappeNotifyPlugin([`${appName}/index.js`]),
],
build: {
outDir: resolve(__dirname, config.outDir),
emptyOutDir: true,
target: "es2020",
minify: true,
lib: {
entry: resolve(__dirname, config.srcDir, config.entry),
formats: ["es"],
fileName: () => "index.js",
},
rolldownOptions: {
output: { assetFileNames: "index.css" },
},
sourcemap: true,
},
resolve: {
alias: { "@": resolve(__dirname, config.srcDir) },
},
});Entry Point Pattern
// myapp/public/js/app_a/app_a.ts
import "./app_a.css";
import { createApp } from "vue";
import type { App } from "vue";
import MyComponent from "./MyComponent.vue";
import { FrappeUI } from "frappe-ui";
const app: App = createApp(MyComponent);
app.use(FrappeUI);
app.mount("#app");frappe-ui provides Frappe-aware Vue components and utilities (API calls, socket integration, etc.).
Frappe Live Reload Plugin
A custom Vite plugin that publishes to Frappe's Redis event bus to trigger browser refresh during development:
function frappeNotifyPlugin(changedFiles: string[]): Plugin {
let config: ResolvedConfig;
let isFirstBuild = true;
return {
name: "frappe-notify",
configResolved(resolvedConfig) {
config = resolvedConfig;
},
writeBundle() {
writeStamp(config);
if (isFirstBuild) {
isFirstBuild = false;
return;
}
notifyFrappeReload(changedFiles);
},
};
}
function writeStamp(config: ResolvedConfig) {
const stampPath = resolve(config.build.outDir, "build-stamp.json");
writeFileSync(stampPath, JSON.stringify({ timestamp: Date.now() }));
}
async function notifyFrappeReload(changedFiles: string[]) {
try {
const redisUrl = process.env.FRAPPE_REDIS_QUEUE || "redis://queue:6379";
const { createClient } = await import("@redis/client");
const client = createClient({ url: redisUrl });
await client.connect();
await client.publish(
"events",
JSON.stringify({
event: "build_event",
message: {
success: true,
changed_files: changedFiles,
live_reload: true,
},
})
);
await client.disconnect();
} catch {
console.log("Build complete - refresh your browser manually");
}
}The Redis message format (build_event with live_reload: true) matches what Frappe's dev server listens for.
Asset Registration in hooks.py
Frappe Bundle (desk-wide)
For global JS loaded on every desk page:
app_include_js = "myapp.app.bundle.js"This references myapp/public/js/app.bundle.js — a Frappe-bundled file processed by esbuild.
List View JS
For doctype-specific list view scripts:
doctype_list_js = {
"Job Card": "public/js/job_card.js",
}Vite-Built Assets
Vite-built assets are typically loaded by the specific page/endpoint that needs them, not globally. The compiled index.js and index.css in public/dist/js/<app>/ are served as static files.
Rollup Alternative
For simpler builds without Vue SFCs, Rollup can be used directly:
// rollup.config.mjs
import { nodeResolve } from "@rollup/plugin-node-resolve";
export default {
input: "src/index.mjs",
output: {
file: "my_page.js",
format: "umd",
name: "MyPage",
inlineDynamicImports: true,
},
plugins: [
nodeResolve({ browser: true, preferBuiltins: false }),
frappeNotifyPlugin(), // Same Redis-based live reload
],
watch: {
include: ["src/**"],
exclude: ["node_modules/**"],
},
};The Rollup frappeNotifyPlugin follows the same pattern as the Vite one — publishing to Redis after each rebuild.
Development Workflow
# Watch mode (live reload on save)
yarn dev:app_a
# Production build
yarn build
# Build specific app
yarn build:app_aDuring --watch mode, every file save triggers a rebuild → Redis publish → browser auto-refresh.
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.