Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Vite & TypeScript Compilation

Build Vue and TypeScript apps with Vite and serve the compiled bundles from Frappe.

Updated
Tags
  • frappe
  • vite
  • typescript
  • vue
Reading time
4 min

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.css

Convention: 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_a

During --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.