Embedding Vue.js (or any modern JS framework) in a Moodle plugin

If you've ever tried to build something with a modern JavaScript framework inside Moodle, you've probably hit a wall pretty quickly. Moodle uses AMD modules. Your framework uses ES modules. Moodle has its own build pipeline. Your framework has Vite or Webpack. Moodle injects jQuery and Bootstrap globally. Your framework wants to own the DOM.

It's a mess. But it's a solvable mess.

I recently built an AI-powered course design plugin for Moodle (more on that another time), and thea frontend is a full Vue.js 3 single-page application - complete with Vue Router, Pinia for state management, and a component library. It runs inside Moodle, gets its config from PHP, talks to Moodle's backend via AJAX, and plays nicely with whatever theme the site happens to be using.

Here's how I got it working, and more importantly, the stuff that tripped me up. While I'm using Vue.js throughout this post, almost everything here applies equally to React, Angular, Svelte, or whatever framework you prefer. The core challenge is the same: bridging the gap between modern JavaScript tooling and Moodle's AMD-based architecture.

The Core Problem

Moodle's JavaScript system is built around AMD (Asynchronous Module Definition) using RequireJS. When you create a Moodle plugin, you're expected to put your JavaScript in amd/src/, and Moodle's grunt-based build pipeline compiles it into amd/build/. Your modules use define() to declare dependencies, and Moodle's loader handles the rest.

This worked great in 2014. But modern frameworks don't speak AMD. They output ES modules. They expect import and export. They use build tools like Vite or Webpack that have their own ideas about how code should be bundled.

So you've got two worlds that don't naturally talk to each other, and somehow you need to make them shake hands.

The Strategy: Build Separately, Bridge at Runtime

Rather than trying to force Vue into Moodle's build system (or vice versa), I kept them completely separate and built a thin bridge between the two. Here's the high-level approach:

  1. Vue lives in its own directory (vue-src/) with its own package.json, its own Vite config, its own everything.
  2. Vite builds the Vue app into ES module format, outputting to a separate js/ directory - deliberately not into Moodle's amd/build/ folder (more on why in a moment).
  3. A tiny AMD wrapper acts as the bridge - Moodle loads it via its normal AMD system, and it injects a <script type="module"> tag to load the Vue bundle.
  4. PHP passes config to the Vue app via a data attribute on a container div.

That's it. Two build systems, one bridge file, and a data attribute. Let's dig into each piece.

Setting Up the Vue Project

The Vue source code lives alongside the Moodle plugin code, not inside it:

local/aicoursearchitect/
  vue-src/               ← Vue source code
    src/
      components/
      stores/
      views/
      composables/
      App.vue
      main.js
    package.json
    vite.config.js
  amd/
    src/                  ← AMD wrapper lives here
    build/                ← AMD wrapper gets copied here
  js/                     ← Vite builds Vue app here (separate from AMD!)
    app.js
    app.css
    app.js.map
  templates/
    main.mustache         ← Moodle template with Vue container
  index.php              ← PHP entry point

This separation is deliberate. The vue-src folder is a completely standard Vue project. You can run npm run dev and get hot module replacement. You can use Vue DevTools. You can pretend Moodle doesn't exist while you're building components. That's the whole point.

Configuring Vite to Output for Moodle

Here's where it gets interesting. Vite needs to build your app in a way that Moodle can consume - but critically, the output needs to live in a separate directory from Moodle's AMD modules:

// vue-src/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
  plugins: [vue()],

  define: {
    'process.env.NODE_ENV': JSON.stringify('production'),
  },

  build: {
    // Output to js/ directory - NOT amd/build/!
    outDir: '../js',
    emptyOutDir: true,               // Safe to empty since js/ is ours alone

    rollupOptions: {
      input: {
        app: resolve(__dirname, 'src/main.js'),
      },
      output: {
        format: 'es',
        entryFileNames: '[name].js',
        chunkFileNames: '[name]-[hash].js',
        assetFileNames: '[name].[ext]',
      },
      preserveEntrySignatures: 'exports-only',
    },

    sourcemap: true,
  },

  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
});

A few things worth calling out:

outDir: '../js' - not ../amd/build/! This is something I learned the hard way. My first version output directly into amd/build/, which seemed logical. The problem? Moodle's RequireJS loader sees everything in amd/build/ and tries to parse it as AMD modules. When RequireJS encounters import and export statements in your ES module bundle, it chokes. By outputting to a separate js/ directory, you keep the ES modules completely out of RequireJS's reach. The AMD wrapper in amd/build/ handles the handoff.

emptyOutDir: true - Since we're writing to our own js/ directory (not sharing space with AMD files), it's safe to let Vite clean it on every build. No risk of nuking your AMD wrapper.

format: 'es' - We're building an ES module, not an AMD module. This is key. We're not trying to make Vue speak AMD. We're building a standard ES module and loading it through a different mechanism entirely.

preserveEntrySignatures: 'exports-only' - This tells Rollup to preserve the export statements from your entry point. Important because the AMD bridge needs to call window.AICourseArchitectApp.init() after the module loads.

process.env.NODE_ENV - Vue checks this internally to decide whether to include development warnings, devtools hooks, and other dev-only code. If you don't define it, you'll get a ReferenceError: Can't find variable: process in the browser. Worse, your bundle will be bloated with dev code. Defining this dropped my initial build from 267KB to 168KB. That's almost 100KB of code that only exists to give you helpful console warnings during development.

Path aliases - The @ alias pointing to src/ is a small quality-of-life thing, but it makes imports in Vue components much cleaner (import Button from '@/components/ui/Button.vue' instead of relative path hell).

The AMD Bridge - The Key Innovation

This is the bit that makes it all work. It's a standard AMD module that Moodle loads normally, but instead of containing your app logic, it dynamically injects a <script type="module"> tag to load the Vue bundle:

// amd/src/init.js
define([], function() {
    return {
        init: function(selector, config) {
            // Load the Vue app CSS
            var cssPath = M.cfg.wwwroot + '/local/aicoursearchitect/js/app.css';
            if (!document.querySelector('link[href="' + cssPath + '"]')) {
                var link = document.createElement('link');
                link.rel = 'stylesheet';
                link.href = cssPath;
                document.head.appendChild(link);
            }

            // If app already loaded (e.g. page navigation), just init
            if (window.AICourseArchitectApp) {
                window.AICourseArchitectApp.init(selector, config);
                return;
            }

            // If script is already loading, wait for it
            var scriptPath = M.cfg.wwwroot + '/local/aicoursearchitect/js/app.js';
            var existingScript = document.querySelector('script[src="' + scriptPath + '"]');
            if (existingScript) {
                existingScript.addEventListener('load', function() {
                    if (window.AICourseArchitectApp) {
                        window.AICourseArchitectApp.init(selector, config);
                    }
                });
                return;
            }

            // Create <script type="module"> to load ES module
            var script = document.createElement('script');
            script.type = 'module';
            script.src = scriptPath;

            script.onload = function() {
                setTimeout(function() {
                    if (window.AICourseArchitectApp) {
                        window.AICourseArchitectApp.init(selector, config);
                    } else {
                        var container = document.querySelector(selector);
                        if (container) {
                            container.innerHTML = '<div class="alert alert-danger">' +
                                'Failed to initialize. Please refresh the page.</div>';
                        }
                    }
                }, 100);
            };

            script.onerror = function(error) {
                console.error('Failed to load AI Course Architect:', error);
                var container = document.querySelector(selector);
                if (container) {
                    container.innerHTML = '<div class="alert alert-danger">' +
                        'Failed to load. Please refresh the page.</div>';
                }
            };

            document.head.appendChild(script);
        }
    };
});

A few things going on here that are worth unpacking.

<script type="module"> instead of dynamic import() - My first version used the browser's import() function directly inside the AMD wrapper. That works in theory, but I ran into issues where RequireJS would interfere with the module resolution. Using a <script type="module"> tag is more reliable because it completely bypasses RequireJS - the browser's native module loader handles everything.

The window.AICourseArchitectApp pattern - The Vue entry point registers itself on the window object when it loads. The AMD wrapper then calls window.AICourseArchitectApp.init() to kick things off. This global handoff is the glue between the two module systems. It's not the most elegant pattern, but it's rock-solid and easy to debug.

The setTimeout of 100ms - ES modules execute asynchronously. Even after the <script> tag fires its onload, the module's top-level code (which sets window.AICourseArchitectApp) may not have run yet. The small delay gives the module time to register itself. In practice, it's nearly instant, but the safety net prevents race conditions.

Duplicate-load protection - The three-way check (already loaded → script already exists → create new script) handles edge cases like Moodle's AJAX-based page navigation where the init function might get called more than once.

The CSS injection follows the same pattern - Vite extracts your styles into a separate app.css file, but Moodle doesn't know about it, so we manually inject a <link> tag.

If you're using React or Angular, this bridge file looks almost identical. You'd just have your framework's entry point register itself on window the same way, and the AMD wrapper would call whatever your bootstrap function is (e.g., ReactDOM.createRoot() for React). The AMD-to-ES-module bridge pattern is completely framework-agnostic.

A Gotcha: Moodle Looks in amd/build/, Not amd/src/

This one cost me an embarrassing amount of debugging time. Moodle's AMD loader looks for modules in amd/build/, not amd/src/. The src/ directory is for your source files, which Moodle's grunt build compiles into build/.

But we're not using Moodle's grunt build for this file. So you need to manually copy it:

cp amd/src/init.js amd/build/init.js
cp amd/src/init.js amd/build/init.min.js

Yes, init.js and init.min.js are identical. Moodle serves the .min.js version in production and the plain .js in debug mode. Since our AMD wrapper is tiny and doesn't need minification, just copy it twice and move on.

I wired this into my npm scripts so it happens automatically as part of the build:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build && npm run copy-amd",
    "copy-amd": "cp ../amd/src/init.js ../amd/build/init.js && cp ../amd/src/init.js ../amd/build/init.min.js && chmod 644 ../amd/build/init.js ../amd/build/init.min.js"
  }
}

The chmod 644 is another gotcha - if your files don't have the right permissions, Moodle's web server can't serve them. Ask me how I know.

The Moodle Side: Mounting the App

On the PHP side, you need two things: a container div for Vue to mount into, and a way to trigger the AMD module.

The Mustache Template

{{! templates/main.mustache }}
<div id="aicoursearchitect-app"
     data-config="{{{config}}}"
     class="aicoursearchitect-container">
    <div class="aicoursearchitect-loading">
        <p>Loading AI Course Architect...</p>
        <div class="spinner-border" role="status"></div>
    </div>
</div>

The triple-mustache syntax {{{config}}} is important - it prevents Moodle from HTML-escaping the JSON string. Double mustaches would turn your " into &quot; and break everything.

The loading indicator inside the div gets replaced when Vue mounts. It's a nice touch for users on slower connections.

The PHP Entry Point

// index.php
$PAGE->set_pagelayout('base');
$PAGE->add_body_class('path-local-aicoursearchitect');

$config = [
    'userid' => $USER->id,
    'contextid' => $context->id,
    'sesskey' => sesskey(),
    'wwwroot' => $CFG->wwwroot,
    'capabilities' => [
        'usebuilder' => has_capability('local/aicoursearchitect:usebuilder', $context),
        'useai' => has_capability('local/aicoursearchitect:useai', $context),
        'managetemplates' => has_capability('local/aicoursearchitect:managetemplates', $context),
        'configure' => has_capability('local/aicoursearchitect:configure', $context),
    ],
    'features' => [
        'ai_enabled' => (bool) get_config('local_aicoursearchitect', 'ai_enabled'),
        'coursegeneration' => (bool) get_config('local_aicoursearchitect', 'feature_coursegeneration'),
        'templates' => (bool) get_config('local_aicoursearchitect', 'feature_templates'),
    ],
];

// Tell Moodle to load our AMD module
$PAGE->requires->js_call_amd('local_aicoursearchitect/init', 'init', [
    '#aicoursearchitect-app',
    $config,
]);

js_call_amd() tells Moodle: "Load the init module from the local_aicoursearchitect plugin, call its init function, and pass these arguments." Moodle handles loading the AMD module at the right time (after the page DOM is ready).

The config object is how PHP talks to Vue. Capabilities, feature flags, session keys - everything the frontend needs to know about the current user and their permissions. This is way cleaner than trying to make AJAX calls to figure out what the user can do.

Receiving Config in Vue

On the Vue side, the entry point needs to do two things: set up the app, and register itself on the window so the AMD bridge can find it.

// vue-src/src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './assets/index.css';

export function init(selector = '#aicoursearchitect-app', config = {}) {
    const app = createApp(App);
    app.use(createPinia());
    app.use(router);
    app.provide('config', config);
    app.mount(selector);
    return app;
}

// Expose on window for AMD module to access
window.AICourseArchitectApp = { init };

// For direct browser usage (dev mode)
if (import.meta.env.DEV) {
    const container = document.querySelector('#aicoursearchitect-app');
    if (container) {
        const configData = container.getAttribute('data-config');
        const config = configData ? JSON.parse(configData) : {};
        init('#aicoursearchitect-app', config);
    }
}

That window.AICourseArchitectApp = { init } line is the handshake point. The AMD bridge loads this file as an ES module, then looks for this global to call init(). It's not the most elegant pattern in the world, but it's dead simple and never fails.

The dev mode block at the bottom is a nice touch - when you're running npm run dev with Vite's dev server, the app auto-initialises by reading config from the container's data-config attribute. This means you can develop without Moodle running at all if you provide mock config data.

Then in any component throughout the app:

import { inject } from 'vue';

const config = inject('config', {});
// config.capabilities.useai → true/false
// config.features.ai_enabled → true/false
// config.wwwroot → 'https://your-moodle-site.com'

For React, you'd do the equivalent with a Context provider. For Angular, a service. The pattern is the same: PHP serializes config into JSON, the template embeds it as a data attribute, and the framework picks it up on mount.

Surviving Moodle's CSS and JS Quirks

This section is where the "framework-agnostic" claim really gets tested. Regardless of whether you're using Vue, React, or Angular, you're going to run into the same CSS and JavaScript environment that Moodle provides - and it has opinions.

CSS Conflicts

Moodle themes apply global CSS. Your framework's components will inherit styles you don't want - margin resets, font sizes, button styles, the works. A few strategies that worked for me:

Namespace everything. I prefix all my top-level CSS classes with .aicoursearchitect- as a safety net. Vue's scoped styles handle most component-level isolation, but having a namespace wrapper catches the edge cases - particularly when a Moodle theme decides that all <button> elements should look a certain way, or when Bootstrap's utility classes collide with your own.

Don't fight Moodle's Bootstrap. Moodle ships with Bootstrap, and themes build on top of it. If you try to bundle your own Bootstrap, you'll get conflicts - duplicate modal backdrops, double-initialised tooltips, and CSS specificity wars you can't win. Instead, lean into Moodle's Bootstrap where you can, and use scoped styles to override where you need to.

Tailwind works great here. I ended up using Tailwind CSS (v3, not v4 - v4 has breaking changes that caused issues) with scoped component styles. Tailwind's utility classes are specific enough that they rarely conflict with Moodle's theme styles. One thing to note: if you're using Tailwind, make sure your tailwind.config.js uses ES module syntax (export default) rather than CommonJS (module.exports) if your package.json has "type": "module". This caught me out when PostCSS failed to load the config.

JavaScript Conflicts

Moodle loads jQuery and Bootstrap JS globally. Vue 3 doesn't depend on jQuery, so this is mostly a non-issue. React and Angular are similarly self-contained. But watch out for global event listeners - if Moodle's JS is listening for click events on certain CSS classes (like .dropdown-toggle or .collapse), and your components happen to use those classes, you'll get unexpected behaviour. Modals are a common culprit here.

The fix is simple: don't use Moodle's CSS classes on your framework components. Stick to your own namespaced classes, and you'll be fine. If you need dropdown or modal behaviour, use your framework's own component libraries rather than relying on the Bootstrap JS that Moodle has loaded.

Theme Compatibility

Your app needs to look reasonable across different Moodle themes. I tested with Boost (the default), Classic, and Adaptable - and I'd strongly recommend testing with at least two themes early in development, not at the end like I did. Each theme has different container widths, font stacks, and spacing conventions that can subtly break your layout.

Using CSS variables for your colour palette helps a lot - you can even read Moodle's theme variables and adapt automatically. For instance, Boost exposes variables like --primary and --secondary that you can reference in your own styles to keep your app visually consistent with the rest of the site.

Routing Inside Moodle

I used Vue Router with hash mode (/#/dashboard, /#/editor, etc.) rather than history mode. Here's why:

Hash mode doesn't require any server-side configuration. With history mode, you'd need to configure Moodle's web server to route all requests to your plugin's index.php, which is a pain and may conflict with other plugins. Hash-based routing is entirely client-side and plays perfectly with Moodle's existing URL structure.

Your Moodle page lives at /local/aicoursearchitect/index.php, and Vue Router handles everything after the #. Clean, simple, no server config.

For React Router, you'd use HashRouter instead of BrowserRouter. Same idea.

The Development Workflow

This setup gives you a genuinely pleasant development experience:

During development, run npm run dev inside vue-src/. You get Vite's blazing-fast hot module replacement. Edit a component, see it update instantly. You can even proxy API calls back to your Moodle dev instance.

For production, run npm run build. Vite compiles the Vue app into js/app.js and js/app.css, and the copy-amd script copies the AMD wrapper into amd/build/. Purge Moodle's caches (Site administration → Development → Purge all caches), reload, and your changes are live.

Commit the built files. This is a departure from typical frontend projects where you'd .gitignore the build output. But Moodle plugins are expected to work straight from a git clone or zip download, without requiring the admin to run npm install && npm run build. So commit both js/ and amd/build/ to your repo.

Build Output: What to Expect

After a production build with everything configured correctly:

✓ js/app.js              ← Vue 3 SPA (Router, Pinia, all components)
✓ js/app.css             ← Compiled Tailwind + scoped styles
✓ js/app.js.map          ← Source maps for debugging
✓ amd/build/init.js      ← AMD wrapper (tiny)
✓ amd/build/init.min.js  ← Copy of wrapper for production mode

The separation is clean: js/ contains your framework code as ES modules, amd/build/ contains the tiny AMD bridge that Moodle knows how to load. RequireJS never touches your framework code, and your framework never needs to know about AMD.

The Checklist: Doing This Yourself

If you're about to embed a modern framework in a Moodle plugin, here's your condensed checklist:

  1. Create your framework project in a subdirectory (e.g., vue-src/, react-src/).
  2. Configure your build tool to output ES modules into a separate js/ directory - not amd/build/, or RequireJS will try to parse them.
  3. Define process.env.NODE_ENV to strip dev-only code (Vue and React both check this).
  4. Write an AMD wrapper in amd/src/ that injects a <script type="module"> tag to load your bundle from js/.
  5. Register your app on window so the AMD wrapper can call your init function after the module loads.
  6. Copy the wrapper to amd/build/ as both .js and .min.js - automate this in your build script.
  7. Set file permissions to 644 on the AMD wrapper files.
  8. Create a Mustache template with a container div and {{{triple-mustache}}} for config.
  9. Pass config from PHP using $PAGE->requires->js_call_amd().
  10. Use hash-mode routing to avoid server-side URL config.
  11. Namespace your CSS to avoid theme conflicts.
  12. Commit both js/ and amd/build/ so the plugin works without a build step.
  13. Purge caches after every JS/CSS change during development.

State Management Considerations

Once your framework app is running inside Moodle, you need to think about state management slightly differently than you would in a standalone SPA.

Your app doesn't own the page. Moodle rendered the header, navigation, and footer. Your app lives in one div in the middle of all that. This means you can't rely on things like page-level state persistence in the URL (history mode) or browser storage APIs that might conflict with Moodle's own usage.

I used Pinia (Vue's recommended state management library) with stores for different concerns - blueprints, templates, AI operations. The key decision was to keep all state in-memory rather than trying to sync to localStorage or sessionStorage. Moodle already has its own caching and session management, and you don't want your framework's state layer fighting with it.

For API communication, I used Moodle's external functions system rather than building a custom REST endpoint. Moodle provides a proper way to define web service functions (with parameter validation, capability checks, and return type definitions), and a built-in AJAX client. From Vue, you call them through Moodle's core/ajax module:

// Inside a Pinia store action
async function loadBlueprints(filter = 'mine') {
    const response = await new Promise((resolve, reject) => {
        window.require(['core/ajax'], function(ajax) {
            ajax.call([{
                methodname: 'local_aicoursearchitect_get_blueprints',
                args: { filter: filter },
            }])[0]
                .done(resolve)
                .fail(reject);
        });
    });

    return response;
}

The window.require(['core/ajax']) call taps into Moodle's existing AMD-loaded AJAX module. It handles sesskey validation automatically, so you don't need to manually pass CSRF tokens. Each external function is registered in db/services.php with its parameter types, return structure, and required capabilities - Moodle validates everything server-side before your PHP code even runs.

This is a much better approach than building a custom api.php endpoint, because you get all of Moodle's security infrastructure for free: parameter validation, capability checks, context validation, and proper error handling. It also means your API works with Moodle's mobile app if you ever want that.

For React or Angular, you'd use the same window.require(['core/ajax']) pattern - it's framework-agnostic since it's just calling Moodle's global AMD module.

Things I'd Do Differently

Looking back, there are a few things I'd reconsider:

I'd output to a separate js/ directory from the start. My first version built directly into amd/build/, which seemed logical. Then I spent ages debugging why RequireJS was throwing errors - it was trying to parse my ES module bundle as AMD. Moving the output to a dedicated js/ directory fixed everything instantly. If I'd thought about why those two module systems shouldn't share a directory, I'd have avoided a solid hour of head-scratching.

I'd set up the process.env.NODE_ENV define from the start. I didn't notice the bundle was bloated with dev code until I looked at the file size. An extra 100KB shipped to every user for no reason - oops.

I'd automate the amd/build/ copy earlier. Manually copying the AMD wrapper got old fast, and I forgot it more than once, leading to "why isn't my change showing up?" debugging sessions. Wiring copy-amd into the build script solved this, but I should have set it up on day one.

I'd use Moodle's external functions from the start. I initially built a custom api.php endpoint because it felt simpler. It was simpler - until I realised I was reimplementing Moodle's parameter validation, capability checking, and error handling. Switching to proper external functions meant more boilerplate PHP, but the security and validation you get for free is absolutely worth it.

I'd test with multiple themes sooner. I built the whole UI against Boost and then discovered a few layout issues in Classic. Testing with at least two themes early on would have caught those.

Wrapping Up

Embedding a modern JavaScript framework in Moodle is absolutely doable. The key insight is: don't try to make your framework conform to Moodle's module system. Instead, build your app the way you normally would, output to a separate directory from AMD, and use a thin AMD wrapper that injects a <script type="module"> tag to bridge the two worlds.

This pattern works for Vue, React, Angular, Svelte - anything that can be built into an ES module (which is everything these days). The AMD wrapper is a single file, and it completely decouples your framework's build system from Moodle's expectations.

The result? You get modern developer tooling, hot module replacement, component-based architecture, and proper state management - all running inside Moodle's PHP-rendered pages. Your users get a fast, responsive interface. And you get to keep your sanity.

Well, most of it, anyway. You'll still need to purge those caches.

← All posts

Comments
IN SHORT