To add jQuery to a Next.js app, run npm install jquery and load it only on the client: inside useEffect in a 'use client' component, through a dynamic import('jquery') or a component loaded with next/dynamic and ssr: false. jQuery needs window and document, and neither exists while Next.js renders your page on the server.
The snippets below were checked against Next.js 16.3 and jQuery 4.0.0, the current version on npm. The same patterns work on Next.js 13 and later. Where jQuery 3 behaves differently, I call it out.
Why jQuery breaks in Next.js
Next.js renders every page on the server first, and that includes Client Components. 'use client' marks the component for hydration in the browser. It does not stop the server from running the module. So any code that runs at import time runs in Node.js too, where there is no DOM.
jQuery 4 checks for a document as soon as it is loaded in Node.js and throws. I reproduced it with a Client Component that does import $ from 'jquery' and only calls $ inside useEffect. next build still fails while prerendering:
Error occurred prerendering page "/".
Error: jQuery requires a window with a document
jQuery 3.7.1 is more forgiving. Loaded in Node.js, it exports a factory function and only throws when you call it, so a static import plus useEffect builds fine on 3.x. Since npm install jquery now gives you 4.x, use one of the patterns below so the import happens only in the browser.
How to add jQuery to a Next.js app (App Router)
Step 1. Install the package. The --save flag from older guides is not needed; npm has saved to dependencies by default since npm 5.
npm install jquery
Step 2. Create a Client Component and import jQuery inside useEffect. Effects run only in the browser, after React has put the element in the DOM.
'use client'
import { useEffect, useRef } from 'react'
export default function PrincipalInput() {
const inputRef = useRef(null)
useEffect(() => {
let cancelled = false
let $input
import('jquery').then(({ default: $ }) => {
if (cancelled) return
$input = $(inputRef.current)
$input.val('5000')
$input.on('focus', () => $input.select())
})
return () => {
cancelled = true
if ($input) $input.off('focus')
}
}, [])
return <input ref={inputRef} id="principalAmount" />
}
Step 3. Render it from any page or layout. The page itself can stay a Server Component.
import PrincipalInput from './PrincipalInput'
export default function Page() {
return <PrincipalInput />
}
Two details matter here. Use a ref instead of a global selector like $('p'), so jQuery only touches the element this component owns. And always remove handlers in the cleanup function. In development, React Strict Mode mounts, unmounts and mounts again, so a missing .off() shows up as double bound handlers.
Load a jQuery component only in the browser with next/dynamic
If a whole widget is built on jQuery, it is cleaner to keep the normal top level import and skip server rendering for that component. next/dynamic with ssr: false does that. Per the Next.js docs, ssr: false only works when it is called from a Client Component, so you need a small wrapper.
// app/Widget.js
'use client'
import { useEffect, useRef } from 'react'
import $ from 'jquery'
export default function Widget() {
const ref = useRef(null)
useEffect(() => {
const $btn = $(ref.current)
$btn.on('click', () => $btn.text('Clicked'))
return () => $btn.off('click')
}, [])
return <button ref={ref}>Click me</button>
}
// app/WidgetLoader.js
'use client'
import dynamic from 'next/dynamic'
const Widget = dynamic(() => import('./Widget'), { ssr: false })
export default function WidgetLoader() {
return <Widget />
}
Render <WidgetLoader /> from your page. This builds cleanly with jQuery 4. The trade off is that the widget’s HTML is not in the server response, so it appears after the JavaScript loads. Pass a loading option to dynamic() if you want a placeholder.
Use jQuery from a CDN with next/script
If you would rather not bundle jQuery, load it from the official CDN with the Script component. The onLoad and onReady callbacks only work in Client Components. onReady also runs again each time the component mounts, which suits code that has to re run after client side navigation.
'use client'
import Script from 'next/script'
export default function JqueryFromCdn() {
return (
<Script
src="https://code.jquery.com/jquery-4.0.0.min.js"
strategy="afterInteractive"
onReady={() => {
window.jQuery('#status').text('jQuery is ready')
}}
/>
)
}
afterInteractive is the default strategy. Use lazyOnload if jQuery is not needed until the browser is idle. beforeInteractive has to be placed in the root layout and cannot be combined with onLoad, so it is rarely the right choice for jQuery.
Adding jQuery in the Pages Router
Older guides, including the first version of this post, suggest a file like this, imported from _app.js:
// Do not do this
window.$ = window.jQuery = require('jquery')
It fails during next build with ReferenceError: window is not defined, because _app.js and everything it imports also run on the server. Pages Router components follow the same rules as Client Components here: put jQuery code in useEffect with a dynamic import, or load the component with next/dynamic and ssr: false. Both snippets above work unchanged in pages/; you just do not need the 'use client' line.
Using jQuery plugins that need a global jQuery
Many older plugins expect window.jQuery to exist before they load. Static imports are hoisted, so assigning the global and then importing the plugin at the top of a file does not run in the order you wrote it. Dynamic imports inside an effect do:
useEffect(() => {
let cancelled = false
async function load() {
const { default: $ } = await import('jquery')
window.$ = window.jQuery = $
await import('some-jquery-plugin') // replace with your plugin
if (!cancelled) $(ref.current).somePlugin()
}
load()
return () => { cancelled = true }
}, [])
Check the plugin against jQuery 4 before you ship. Version 4 removed long deprecated helpers such as jQuery.trim, jQuery.isArray, jQuery.parseJSON and jQuery.isFunction, and plugins that still call them will throw. If you cannot update the plugin, install jquery@3 instead.
Should you use jQuery in a Next.js app at all?
React expects to own the DOM it renders. When jQuery adds, removes or rewrites nodes inside a React tree, the next render can overwrite those changes or fail to reconcile them. jQuery is a reasonable choice when you are wrapping an existing plugin, such as a date picker or a slider, inside one component that React does not re render. For new code, state, refs and plain DOM APIs cover nearly everything jQuery is usually pulled in for. I listed common replacements in vanilla JavaScript equivalents of jQuery tasks, and if you keep jQuery, the jQuery performance tips still apply. For the React side, see 10 React hooks worth knowing; several of them replace typical jQuery jobs like outside click handling and debouncing.
FAQ
Does Next.js support jQuery?
Yes, as long as jQuery only runs in the browser. Next.js has no special integration for it; you load it like any other browser only library.
Can I use jQuery in a Server Component?
No. Server Components never run in the browser, so there is no DOM for jQuery to work with. Move the jQuery code into a component marked 'use client'.
Why do I get “window is not defined” or “jQuery requires a window with a document”?
Something is loading or calling jQuery while the page renders on the server. Look for a top level import $ from 'jquery' or a window.$ = ... assignment in a shared file, and move it into useEffect or behind next/dynamic with ssr: false.
Why is $ undefined in my component?
A bundled jQuery is not global. Import it in the file that uses it, or assign window.$ yourself in the browser as shown in the plugin example. With the CDN approach, window.jQuery exists only after the script loads, so use it inside onLoad or onReady.
Should I install jQuery 4 or jQuery 3?
Use jQuery 4 for new work. It drops support for Internet Explorer 10 and older and removes deprecated APIs. Stay on jQuery 3 if you depend on plugins that have not been updated for 4.

Documenting the safe compromise instead of shaming jQuery-in-Next helped real maintenance work. Thanks for that tone.
Old datepicker forced a client-only jQuery path. Copied your pattern and moved on without a rewrite.
Dynamic import boundaries contained the jQuery tax on bundle size. Not elegant, but stable in production.
Legacy widget still needs jQuery inside a Next app. Client-only load guardrails kept SSR from melting.