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.
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:
<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:
Uncaught: TypeError: Cannot read properties of undefined (reading 'fn')
carousel class: owl-carousel | window.jQuery: undefinedThe 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):
(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:
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:
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:
Uncaught: TypeError: a.camelCase is not a functionOwl 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:
Uncaught: TypeError: e.camelCase is not a function
carousel class: owl-carousel | window.jQuery: functionFix#
- Pin jQuery 3 in your own project, so that npm stops resolving Owl's open range to 4.0:
npm install jquery@3.7.1- Create a module whose only job is to publish jQuery on
window:
// src/js/jquery.js: evaluate jQuery as a module, then publish it on window
import $ from 'jquery';
window.jQuery = window.$ = $;
export default $;- Make that module the first import. The smallest change to the question's page swaps a single line, the jQuery import:
<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>:
// 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):
npx vite build
npx vite previewIn the reproduction, both the single-line version and the bare-import version gave this in the built page:
console.log: owl initialised: owl-carousel owl-loaded owl-drag
carousel class: owl-carousel owl-loaded owl-drag | window.jQuery: functionIn 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:
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:
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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Vite 'Top-level await is not available' build error
Why vite dev works but vite build fails on a top-level await, which setting fixes the build and which one only fixes dev, reproduced on Vite 2.9, 5.4, 7.3 and 8.3.
Fix Vite: crypto.hash is not a function
Check the Node binary that actually starts Vite, distinguish API availability from the supported version range, and verify the fix without replacing the lockfile.
Vite build Unexpected token in node_modules: fix define
Why vite build fails on react-router-dom while vite dev works, which define line triggers it, and a polyfill setup that builds, reproduced on Vite 2.9.12.


