TypeScript

Owl Carousel not working after Vite build: reading 'fn'

Why Owl Carousel works under the Vite dev server but dies in dist, and the two changes a fresh npm install now needs before the carousel loads again.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

Owl Carousel 2 reads jQuery from the global window.jQuery when its file runs. Under vite (dev), a path import of node_modules/jquery/dist/jquery.slim.min.js is evaluated by the browser with no CommonJS module object in scope, and jQuery sets that global. In vite build, the same file is bundled inside a CommonJS wrapper, jQuery hands itself to module.exports instead, the global stays undefined, and Owl throws Cannot read properties of undefined (reading 'fn'). The fix is to set window.jQuery yourself in a small module that is imported before owl.carousel. On a new install you also need to pin jQuery 3, because npm now pulls in jQuery 4, which Owl Carousel 2.3.4 cannot run on.

This page starts from the Stack Overflow question "Owl-carousel is not working after build the project using vite", whose author worked around it by loading both libraries from a CDN.

The error, reproduced#

The question imports the two libraries by path from an inline module script:

html
<script type="module">
  // node_modules --> not working after build
  import './node_modules/jquery/dist/jquery.slim.min.js';
  import './node_modules/owl.carousel/dist/owl.carousel.min.js';
 
  // customize owl carousel
  import './src/js/owl-carousel.js';
</script>

That setup was reproduced on 30 October 2026 with Vite 8.3.5, owl.carousel 2.3.4 and jQuery 3.7.1, on Windows 11 with Node v26.7.0. Docker was not available, so everything ran on the host Node, and the page was loaded in headless Google Chrome 156 driven over the DevTools protocol. With vite (dev), the carousel initialises and gets its owl-loaded class. After vite build and vite preview, the console shows:

text
Uncaught: TypeError: Cannot read properties of undefined (reading 'fn')
carousel class: owl-carousel | window.jQuery: undefined

The question shows the same reading 'fn' message, only with a different hashed bundle name. window.jQuery is undefined in the built page. That one value explains the crash.

Why the dev server and the bundle disagree#

Owl Carousel wraps each of its parts in a function that receives jQuery from the global object. In the owl.carousel 2.3.4 source the core file is shaped like this (body abridged, last line verbatim):

js
(function($, window, document, undefined) {
  // ... the whole plugin, written against $
})(window.Zepto || window.jQuery, window, document);

When neither global exists, $ is undefined inside the plugin, and the first $.fn access throws.

jQuery itself decides at load time whether to create that global. The opening wrapper of jQuery 3.7.1's dist/jquery.js checks for a CommonJS module:

js
if ( typeof module === "object" && typeof module.exports === "object" ) {
 
	// For CommonJS and CommonJS-like environments where a proper `window`
	// is present, execute the factory and get jQuery.
	// For environments that do not have a `window` with a `document`
	// (such as Node.js), expose a factory as module.exports.
	// This accentuates the need for the creation of a real `window`.
	// e.g. var jQuery = require("jquery")(window);
	// See ticket trac-14549 for more info.
	module.exports = global.document ?
		factory( global, true ) :
		function( w ) {
			if ( !w.document ) {
				throw new Error( "jQuery requires a window with a document" );
			}
			return factory( w );
		};
} else {
	factory( global );
}

The true is the noGlobal flag. The same file only runs window.jQuery = window.$ = jQuery when typeof noGlobal === "undefined". So jQuery sets the global only when no CommonJS module object is in scope as it runs.

In development, the browser fetches the file from its own path. The Vite guide on dependency pre-bundling says pre-bundling covers "bare imports" and "only applies in development mode". A ./node_modules/... path is not a bare import. The reproduced dev stack traces show the file served from /node_modules/jquery/dist/jquery.slim.min.js, not from Vite's pre-bundle folder. The page imports it from a <script type="module">, so the browser evaluates it as a native ES module, where no CommonJS module variable exists. jQuery takes the else branch and sets window.jQuery.

In the production build, the reproduction's unminified bundle (vite build --minify false) shows jQuery wrapped in a CommonJS helper that supplies a module object:

text
10:var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod, mod.exports), cb = null), mod.exports);
42:var require_jquery_slim_min = /* @__PURE__ */ __commonJSMin(((exports, module, this$1) => {
46:		"object" == typeof module && "object" == typeof module.exports ? module.exports = e.document ? t(e, !0) : function(e) {

jQuery takes the CommonJS branch, sets noGlobal, and the window never gets jQuery. The question's author reports that loading the same two libraries from a CDN does work after the build; that variant was not part of this reproduction.

The second failure on a fresh install: jQuery 4#

The npm registry entry for owl.carousel 2.3.4 lists "jquery": ">=1.8.3" as a dependency, and the jquery package has 4.0.0 as its latest tag. A plain npm install owl.carousel today therefore installs jQuery 4.0.0. The reproduction showed this, and with it the original page fails even under the dev server:

text
Uncaught: TypeError: a.camelCase is not a function

Owl Carousel's trigger method calls $.camelCase(...) (same 2.3.4 source). The jQuery Migrate warnings list states: "This method was never documented and is removed as of jQuery 4.0." The project's own README now opens with "YEAH SO THIS IS PRETTY MUCH DEAD", so no release adapted to jQuery 4 should be expected.

This matters for the fix. With the global fix applied but jQuery 4 still installed, the reproduced build gets past .fn and then fails here instead:

text
Uncaught: TypeError: e.camelCase is not a function
carousel class: owl-carousel | window.jQuery: function

Fix#

  1. Pin jQuery 3 in your own project, so that npm stops resolving Owl's open range to 4.0:
bash
npm install jquery@3.7.1
  1. Create a module whose only job is to publish jQuery on window:
js
// src/js/jquery.js: evaluate jQuery as a module, then publish it on window
import $ from 'jquery';
window.jQuery = window.$ = $;
export default $;
  1. Make that module the first import. The smallest change to the question's page swaps a single line, the jQuery import:
html
<script type="module">
  import './src/js/jquery.js';
  import './node_modules/owl.carousel/dist/owl.carousel.min.js';
 
  // customize owl carousel
  import './src/js/owl-carousel.js';
</script>

The tidier version, close to the question's accepted answer, uses bare imports and a single entry file. The page loads it with <script type="module" src="/src/js/owl-carousel.js"></script>:

js
// src/js/owl-carousel.js
import $ from './jquery.js'; // runs first: window.jQuery now exists
import 'owl.carousel'; // reads window.jQuery when it evaluates
 
$(function () {
  $('.owl-carousel').owlCarousel({ loop: true, margin: 20, items: 1 });
});

Both versions were reproduced with jQuery 3.7.1. Note that import $ from 'jquery' loads the full jQuery build and not the slim file the question used, which is why the reproduced bundle grew from about 114 kB to 132 kB. The slim variant was not tested with this fix.

This is the same "own module, imported first" pattern used for the missing Node global in Vite 'global is not defined'.

Verify the fix#

Build, then serve the build output instead of opening dist/index.html from disk (the Tailwind dev-vs-dist page explains why a file opened from disk misleads you):

bash
npx vite build
npx vite preview

In the reproduction, both the single-line version and the bare-import version gave this in the built page:

text
console.log: owl initialised: owl-carousel owl-loaded owl-drag
carousel class: owl-carousel owl-loaded owl-drag | window.jQuery: function

In your own browser, open the console on the preview URL and check that .owl-carousel has gained owl-loaded. Owl adds that class only after setup has completed.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Assigning window.jQuery between two imports does not work#

The obvious shortcut is to put everything in one file:

js
import $ from 'jquery';
window.jQuery = $;
import 'owl.carousel';

It was run as a negative control and fails in both modes. The build throws reading 'fn' as before, and the dev server throws it from the pre-bundled /node_modules/.vite/deps/owl__carousel.js. MDN's import reference explains why: import declarations are hoisted, and "their side effects are produced before the rest of the module's code runs". owl.carousel is evaluated before the assignment line ever executes. The assignment has to live in its own module, so that evaluating that module is itself one of the imports.

The inject plugin alternative#

The second answer on the question uses @rollup/plugin-inject to rewrite the global reads at build time instead of creating a global. With @rollup/plugin-inject 5.0.5 on Vite 8.3.5 and jQuery 3.7.1, this config was reproduced working in both vite and vite build, with import $ from 'jquery'; import 'owl.carousel'; in the entry file and no assignment:

js
import { defineConfig } from 'vite';
import inject from '@rollup/plugin-inject';
 
export default defineConfig({
  plugins: [
    inject({
      $: 'jquery',
      jQuery: 'jquery',
      'window.jQuery': 'jquery',
    }),
  ],
});

In that run the carousel loaded, but window.jQuery stayed undefined. Any inline script or other plugin that expects a real global will not find it. Choose the plain module when other code on the page uses $ too. It was not tested with jQuery 4, so the pin from step 1 still applies.

For other dev/build splits in Vite with a different cause, see import.meta.env undefined on production build. If you are starting a new carousel, the Owl Carousel README itself points you to another library. On an existing site, two changes keep it working through vite build: jQuery pinned to 3.7.1, and a one-line module that puts jQuery on window before the plugin loads.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote