Update v5.0 - Preline MCP, AI Prompts, Animated Icons and more. Visit Changelog

Next.js

Using Preline UI with Next.js

A practical guide to wiring Preline UI into Next.js without fighting Server Components, client navigation, and strict TypeScript.

Next.js adds one extra rule to the usual React integration: Preline UI must stay on the client side. React and Server Components can produce the markup, but Preline UI should only scan and wire that markup after it exists in the browser.

The current Preline UI package exposes browser-side module entries that fit App Router boundaries: preline/non-auto for explicit scans, named plugin classes for manual instances, and single-plugin packages for smaller client surfaces.

Start with the Next.js mental model

Preline UI is a DOM-driven Tailwind CSS component system. Next.js renders React trees, often across a Server Component and Client Component boundary. Preline UI reads the browser DOM and attaches behavior to matching markup after hydration.

That means the integration should stay inside a client boundary. Let Next.js render and hydrate first, then initialize Preline UI from a client component.

Keep the loader behind a client boundary

Create a tiny client component for Preline UI initialization. Dynamic import keeps the module browser-only and avoids touching the DOM during the server render.

app/components/PrelineClient.tsx
                        
                          "use client";

                          import { usePathname } from "next/navigation";
                          import { useEffect } from "react";

                          export default function PrelineClient() {
                            const pathname = usePathname();

                            useEffect(() => {
                              let cancelled = false;

                              import("preline/non-auto").then(({ HSStaticMethods }) => {
                                if (cancelled) return;
                                HSStaticMethods.cleanCollection();
                                HSStaticMethods.autoInit();
                              });

                              return () => {
                                cancelled = true;
                              };
                            }, [pathname]);

                            return null;
                          }
                        
                      

Add it near the end of the App Router layout body so route content is already part of the committed React tree when the effect runs.

app/layout.tsx
                        
                          import PrelineClient from "./components/PrelineClient";

                          export default function RootLayout({ children }: LayoutProps<"/">) {
                            return (
                              <html lang="en">
                                <body>
                                  {children}
                                  <PrelineClient />
                                </body>
                              </html>
                            );
                          }
                        
                      

LayoutProps<"/"> is the typed-routes helper the current create-next-app scaffold generates for the root layout's props. Older projects, or ones with typed routes turned off, use { children: React.ReactNode } instead: both describe the same shape.

Reinitialize after App Router navigation

App Router navigation can replace route markup without reloading the page. Use usePathname() as the dependency that tells the Preline UI loader to scan the new DOM.

autoInit is collection-aware. It filters stale nodes that are no longer in the document and skips elements that already have plugin instances, so repeated route-level scans are expected. Call cleanCollection() right before it on every pathname change, not just once: it resets each plugin's bookkeeping array so the new page's elements are treated as unseen and get real instances, rather than being silently skipped because the array still holds entries from a route that no longer exists in the DOM.

Note that the App Router's root layout itself does not remount on navigation: only children does. A PrelineClient mounted once near the layout's root stays mounted across every route; usePathname() as the effect dependency is what makes the same instance re-run per route, not a remount.

If you are still using the Pages Router, use the same idea with router.asPath from next/router as the effect dependency.

Choose imports by the level of control you need

For a full Preline UI installation, preline/non-auto is the best Next.js default. It gives you HSStaticMethods and named plugin classes without automatic page-load initialization.

preline.ts
                        
                          const { HSStaticMethods } = await import("preline/non-auto");

                          HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
                          HSStaticMethods.autoInit(["dropdown", "overlay"]);
                        
                      

Inside a client component, named imports from the same entry are also useful for manual instances.

Client component
                        
                          import { HSDropdown, HSStaticMethods } from "preline/non-auto";
                        
                      

Single plugin packages keep small Next.js surfaces focused

Preline UI plugins can also be consumed from single-plugin dependencies when those packages are available in your dependency set, for example @preline/dropdown, @preline/overlay, @preline/select, or @preline/range-slider. This is useful when a route segment only needs one or two interactive primitives.

Terminal
                        
                          npm install @preline/dropdown
                        
                      
DropdownClient.tsx
                        
                          "use client";

                          import { useEffect } from "react";
                          import HSDropdown from "@preline/dropdown/non-auto";

                          export default function DropdownClient() {
                            useEffect(() => {
                              HSDropdown.autoInit();
                            }, []);

                            return (
                              <div className="hs-dropdown relative inline-flex">
                                ...
                              </div>
                            );
                          }
                        
                      

In that single-package setup, the auto entry is import "@preline/dropdown". It is useful for simple static pages. In Next.js client components, /non-auto keeps initialization aligned with hydration and navigation timing.

@preline/dropdown, @preline/overlay, @preline/select, and @preline/range-slider each publish only index.d.ts for their default entry, with no matching non-auto.d.ts, and none of these packages declares an "exports" map in package.json. Under the allowJs: true that create-next-app sets by default, this doesn't fail the build: TypeScript quietly resolves /non-auto against the plain .js file instead and the import types as any, with no error and no warning. Confirmed directly by calling a nonexistent method on the imported class and watching tsc --noEmit report nothing. Turn allowJs off and the same import instead fails loudly with TS7016: Could not find a declaration file for module '@preline/dropdown/non-auto'. Either way, the fix is the same: declare the subpath once as an ambient module that re-exports the default entry's real type, and every import of it project-wide is fully typed, with real errors on real mistakes, no per-call-site cast needed:

global.d.ts
                        
                          declare module "@preline/dropdown/non-auto" {
                            export { default } from "@preline/dropdown";
                          }
                        
                      

Repeat this per package for whichever of @preline/overlay, @preline/select, or @preline/range-slider you actually install.

Use manual instances for component-owned nodes

autoInit is good for page-level scans. A manual instance is better when one client component owns one plugin root and can destroy it directly.

Dropdown.tsx
                        
                          "use client";

                          import { useEffect, useRef } from "react";
                          import {
                            HSDropdown,
                            type IHTMLElementFloatingUI,
                          } from "preline/non-auto";

                          export default function Dropdown() {
                            const dropdownRef = useRef<HTMLDivElement>(null);

                            useEffect(() => {
                              if (!dropdownRef.current) return;

                              const dropdown = new HSDropdown(
                                dropdownRef.current as unknown as IHTMLElementFloatingUI,
                              );

                              return () => {
                                dropdown.destroy();
                              };
                            }, []);

                            return (
                              <div ref={dropdownRef} className="hs-dropdown relative inline-flex">
                                <button className="hs-dropdown-toggle" type="button">
                                  Toggle
                                </button>
                                <div className="hs-dropdown-menu hidden">Menu</div>
                              </div>
                            );
                          }
                        
                      

The cast is only for strict TypeScript. At runtime the plugin receives the actual dropdown root element and augments it with Floating UI internals.

Cleanup matters because Preline UI keeps registries

Preline UI stores plugin instances in internal collections such as window.$hsDropdownCollection. That registry lets plugins coordinate without a React context and is part of why the same codebase works in plain HTML, React, Vue, Svelte, Angular, SolidJS, and Next.js.

In Next.js, call destroy() for manual instances. For route-level scans, autoInit filters each collection down to nodes still document.contains()-connected before creating new instances, and cleanCollection() resets a collection to empty outright.

Cleanup
                        
                          HSStaticMethods.cleanCollection("dropdown");
                          HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
                        
                      

Neither of those calls destroy() on what they remove from the collection; they only drop the array entry. For most plugins that's harmless: their generated DOM lives entirely inside the plugin's own root element, so React's normal unmount on a route change removes it regardless of what Preline's own bookkeeping still thinks is registered. File Upload is a real exception, not a theoretical one. Its Dropzone dependency appends a hidden <input type="file"> directly to document.body, not inside the [data-hs-file-upload] container it's attached to. Navigate away from a page with a File Upload instance and back through client-side routing, and that input is never removed: autoInit's own document.contains() filtering just stops tracking it, and cleanCollection() discards the reference needed to destroy it properly. Measured directly in a real app: document.querySelectorAll('input[type=file]').length read 2 on first load and grew to 4, then 6, after two navigations away from and back to the same page.

The fix is to read the live instances out of Preline's own window.$hsFileUploadCollection and destroy each one, in the effect's cleanup function, before the next run's cleanCollection() discards the reference to it:

PrelineClient.tsx
                        
                          declare global {
                            interface Window {
                              $hsFileUploadCollection?: { element: { destroy: () => void } }[];
                            }
                          }

                          // Inside the same effect as HSStaticMethods.autoInit() above:
                          return () => {
                            cancelled = true;
                            window.$hsFileUploadCollection?.forEach(({ element }) => element.destroy());
                          };
                        
                      

This is scoped to File Upload deliberately. Don't add the same cleanup for other plugins without first confirming they actually leak a DOM node the same way, since most don't: only Dropzone reaches outside the element Preline attached it to.

Optional dependencies only matter for the plugins that use them

Most core plugins do not use jQuery. Dropdowns, overlays, tooltips, popovers, tabs, and similar components use plain JavaScript. Positioning behavior uses @floating-ui/dom.

jQuery is only relevant for Datatable, which needs the datatables.net-dt package specifically, not the bare datatables.net core package: datatables.net-dt bundles the default DataTables styling integration Preline's plugin markup expects (Preline's own @preline/datatable plugin README installs it the same way: npm i jquery datatables.net-dt @preline/datatable), and it depends on datatables.net itself, which is what actually needs jQuery. Range Slider uses the JavaScript API from noUiSlider. These dependencies do not need to be loaded for dropdowns, overlays, tabs, or tooltips.

Datepicker also needs lodash on window._, not just Vanilla Calendar Pro. This is easy to miss: Datepicker's own availability check only looks for window.VanillaCalendarPro, but its option-merging logic calls into lodash unconditionally as soon as an instance is constructed. Skip lodash and HSStaticMethods.autoInit() throws a ReferenceError: _ is not defined from inside Preline UI's own bundle the moment it reaches a Datepicker element. File Upload depends on lodash too, for unrelated general-utility reasons, so one window._ assignment covers both plugins.

If you initialize optional plugins through HSStaticMethods, make the optional library available before the first import of preline/non-auto. The static methods build their plugin map when that module is loaded, and that module can be cached by your general Preline client loader.

Terminal
                        
                          npm install nouislider
                        
                      
RangeSliderClient.tsx
                        
                          "use client";

                          import { useEffect } from "react";
                          import noUiSlider from "nouislider";

                          export default function RangeSliderClient() {
                            useEffect(() => {
                              (
                                globalThis as typeof globalThis & {
                                  noUiSlider: typeof noUiSlider;
                                }
                              ).noUiSlider = noUiSlider;

                              import("preline/non-auto").then(({ HSRangeSlider }) => {
                                HSRangeSlider.autoInit();
                              });
                            }, []);

                            return null;
                          }
                        
                      

Render the Range Slider markup in the route or component that owns it. For a late-loaded optional plugin, use the direct plugin class like HSRangeSlider. If you want to use HSStaticMethods.autoInit(["range-slider"]) instead, expose noUiSlider in the same first loader before any other preline/non-auto import happens.

The practical checklist

  • Keep Preline UI initialization inside client components.
  • Use preline/non-auto when you need route-aware lifecycle control.
  • Run cleanCollection() then autoInit after App Router navigation with usePathname(), resetting the bookkeeping before every rescan, not just the first one.
  • When available in your dependency set, use single plugin packages such as @preline/dropdown/non-auto when a route only needs one plugin. Add an ambient declare module re-export for each one you use, since /non-auto ships with no type declarations of its own; with create-next-app's default allowJs: true, that fails silently as any rather than a build error.
  • Use manual instances and destroy() for reusable client components that own one plugin root.
  • For File Upload specifically, destroy each live instance from window.$hsFileUploadCollection in your route-scan cleanup, since Dropzone's hidden file input lives outside the plugin root and survives a normal unmount otherwise.
  • Load optional third-party libraries before the first preline/non-auto import when using HSStaticMethods, or initialize late optional plugins with their direct classes.

© 2026 Preline Labs.