Laravel and Vite: A Love Story Ruined with Cross-Origin
This title might sound dramatic, but trust me, it's worth your read. Dive in!
It's great to be back to my blog. I've been away due to numerous conference preparations and a major internal updates to this website. Although these aren't today's topics, I'll certainly cover them in future posts. Also, this is my first article on Laravel, and it won't be the last!
Today, I want to share an experience I had with Laravel and Vite because of the strictness of the Web Worker API.
The Laravel and Vite Integration
Before delving into the problem, it's crucial to understand how Vite operates within a Laravel project.
First, what is Vite? It's a fast frontend build tool that powers next-gen web applications. It allows you to construct your frontend assets (JavaScript, CSS, etc.) efficiently, especially during development. The core of Vite is a plugin pipeline that transforms source files into the required outputs—a point we'll revisit soon.
And Laravel? It's a web application framework known for its expressive, elegant syntax. Laravel eases common tasks like authentication, routing, sessions, and caching, aiming to make development enjoyable without compromising functionality. And it excels at it!
When you embrace Laravel and Vite, you get a formidable stack for web application development.
Sounds great, right? But how does it works practically? Let me illustrate.

This image offers a high-level overview of the Laravel and Vite architecture. During development, two servers run: one for Laravel and one for Vite.
- A user requests a page from the Laravel server.
- The Laravel server sends the HTML page to the user. Before sending the response, Laravel adds a link tag to the entry script served by the Vite server. For example, you might find this line in the HTML returned by the Laravel server:
<script type="module" src="http://localhost:5173/index.ts"></script>
- The user's browser loads the HTML and detects the script tag pointing to the Vite server, then requests the script from the Vite server.
- The Vite server delivers the script to the user's browser.
This process is straightforward and highly effective during development. It gives you access to Vite's full power with the plugin pipeline and hot module replacement (HMR). Many developers might not fully appreciate how seamlessly this integration works compared to other tools.
However, sometimes things get tricky. And that's where our story begins.
The Context
For a project, I had to use the Monaco Editor, a browser-based code editor and the core of Visual Studio Code. The aim was to display a JSON in the code editor and validate it against a JSON schema. Users can edit this JSON, and the Monaco Editor, with JSON schema validation, will show in real time whether the JSON is valid or suggest corrections. Amazingly efficient when it works! And it has been much more complexe than expected.

To achieve this, the Monaco Editor requires starting some Web Workers. Why and what are Web Workers?
JavaScript, especially in browser environments, operates on a single thread, meaning it executes one task at a time. So, if a piece of code takes long to execute, the browser becomes unresponsive which degrades the user experience. This is why web developers advise against blocking the main thread.
To handle long tasks without affecting performance, we have two options:
Asynchronous JavaScript: This involves breaking down tasks into smaller chunks and executing them asynchronously. This way, the main thread remains free to handle other tasks.
Web Workers: For more complex tasks, especially those requiring heavy computation, we use Web Workers. A Web Worker is essentially a JavaScript file running in the background, separate from other scripts, with which it can communicate via messages.
Here's a simple example of a Web Worker script:
globThis.onmessage = function (e) {
console.log('Message received from main script')
const workerResult = `Result: ${e.data[0] * e.data[1]}`
console.log('Posting message back to main script')
postMessage(workerResult)
}
With its main script:
const worker = new Worker('worker.js')
worker.addEventListener('message', (e) => {
console.log('Message received from worker')
console.log(e.data)
})
worker.postMessage([2, 3])
In this example, the main script creates a new worker and sends it a message using postMessage
. The worker receives and responds with postMessage
. Due to JavaScript's event-driven nature, the main script listens for the worker's message using addEventListener
.
In our case, Monaco Editor leverages Web Workers for certain features, including JSON schema validation.
Note
Using a Web Worker allows the Monaco Editor to run a LSP server in the background for each language, providing more advanced features without impacting the main thread and the user experience.
The Issue
But here's the catch:
The script passed to the Web Worker must be accessible from the same origin as the main script. This is where the problem arises.
Warning
This is a security feature to prevent cross-origin attacks so it's impossible to bypass it.
An origin is defined by a URL's scheme, host, and port. This means https://example.com
and https://example.com:1024
are considered different origins.
Imagine we're on the page https://example.com
and you want to create a Web Worker. The script URL passed to the Web Worker must be accessible from https://example.com
.
This means that this works:
<script>
const worker = new Worker('worker.js')
</script>
But this doesn't:
<script>
const worker = new Worker('https://another-origin.example.com/worker.js')
</script>
Do you see the issue now?
Our Laravel server runs on http://localhost:8000
, while Vite is on http://localhost:5173
. http://localhost:8000
and http://localhost:5173
are two different origins. This means the browser won't load the Web Worker script from the Vite server, resulting in a cross-origin issue due to running dual servers.
In the browser, we can easily spot this error with the following warning:
app.js:21 Failed to construct 'Worker': Script at 'http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/language/json/json.worker.js?worker_file&type=module' cannot be accessed from origin 'http://localhost:'.
This error is a showstopper. The Monaco Editor can't function properly without the Web Worker script. Let's find a solution.
Spoiler alert, I searched high and low online for a working solution. Sadly, I couldn't find one. So I try to tackle the issue myself. After extensive research and trials, I found a solution that I'm sharing it with you in this article.
The problematic code looks like this:
import * as monaco from 'monaco-editor'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
globalThis.MonacoEnvironment = {
getWorker(workerId, label) {
switch (label) {
case 'json':
return new JsonWorker()
default:
return new EditorWorker()
}
},
}
// Monaco Editor initialization and Diagnostic feature
When Monaco is initialized, it tries to load the Web Worker script from the Vite server. This is where the cross-origin issue arises.
Note
In frameworks like Adonis—where Vite serves as middleware—this issue doesn't occur because Vite runs within the same application as the server. But in Laravel, with two separate servers, we needed a workaround.
Note
This issue doesn't exist if assets are built and loaded from /public/build
, as everything is served by the Laravel server. However, this significantly impacts the developer experience, requiring asset rebuilding and page refreshing for every change. This isn't aligned with Vite's experience or its on-demand philosophy.
The Solution
Now we have a clear understanding of the issue and the origin of the problem. Let's dive into the solution.
First, we can examine the worker script URL:
monaco-editor/esm/vs/editor/editor.worker?worker
It's a worker script—not intended for direct browser loading. Notice the ?worker
query parameter, a Vite hint to wrap the script for easy use. We can inspect the response to have a better understanding.
export default function WorkerWrapper(options) {
return new Worker(
'http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker_file&type=module',
{
type: 'module',
name: options?.name
}
)
}
Thanks to the ?worker
query parameter, Vite automatically wraps the script in a function that returns a new Worker
instance. So when we import the script, we can easily invoke the function to start the worker without having to manually create a new Worker
instance with the correct URL and options.
The worker use the following script:
http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker_file&type=module
The http://localhost:5173
origin belongs to the Vite server and it's the root of the issue.
To be successful, the script URL must match our Laravel server's origin:
http://localhost:8000/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker_file&type=module
But how to modify the URL dynamically to match the Laravel server origin?
With a Vite plugin! It's the core of Vite to transform source files on the fly. With a plugin, we could intercept the worker script request and adjust the URL to match the Laravel server origin.
In our vite.config.ts
, we can define a plugin to rewrite the worker script URL:
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
(() => {
return {
name: 'monaco-editor:rewrite-worker',
transform(code, id) {
if (this.environment.mode !== 'dev') {
return
}
if (id.includes('worker')) {
return code.replace(
'__laravel_vite_placeholder__.test',
'localhost:8000',
)
}
},
}
})(),
],
})
Note
Here, we're replacing __laravel_vite_placeholder__.test
instead of http://localhost:5173
, as this URL is typically managed by the Laravel Vite plugin.
With this plugin, we can now load the Monaco Editor worker script since the origin matches the Laravel server. In production, the script URL will be correct and working as expected so we don't need to rewrite the URL. Build files are served from the same origin using a single server like Nginx, Apache or Caddy.
However, this poses another challenge: the worker script loads from the Laravel server, but only Vite can serves it—the Laravel server isn't aware of this script and the request will just return a 404 error.
But, we have full control over the Laravel server. We can create a route to proxy requests to the Vite server, allowing the Laravel server to serve the worker script.
This seems viable, faisable and elegant enough. Let's implement it.
We can create a Laravel route, available only in development, to proxy requests to the Vite server. Let's craft a proxy.dev.php
file within Laravel's routes
directory and including it in routes/web.php
just during development.
if (!app()->isProduction()) {
require __DIR__ . '/proxy.dev.php';
}
Next, define our proxy route in proxy.dev.php
:
<?php
use Illuminate\Support\Facades\Route;
Route::get('/node_modules/{any}', function ($any) {
$url = "http://localhost:5173/node_modules/{$any}";
return response()->stream(function () use ($url) {
// Directly stream the remote content without loading it fully into memory
readfile($url);
}, 200, ['Content-Type' => 'text/javascript']);
})->where('any', '(.*)');
Note
I do my best to optimize the proxy to efficiently stream the content without adding too much overhead. If you think of a better way to do it, feel free to share it with me in the comments.
And there you have it! The worker script loads, and the Monaco Editor operates perfectly.

One challenge was finding a solution that works well in development, during build, and in production. This approach seems to be an elegant compromise—simple, effective, and clean. So far, it's working perfectly.
Here's an overview of the solution:

Conclusion
This issue was indeed a challenge. Integrating the Monaco Editor with JSON schema validation was essential for our project. Unfortunately, the cross-origin headache and scant online resources forced me to create my own solution. But I found one!
I hope this article assists those facing similar issues. The key takeaway: mastering your tools is crucial. Although Vite plugins might seem daunting, understanding them removes all limitations allowing you to solve complex problems.
Thanks for reading! My name is Estéban, and I love to write about web development.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.