SSR safety

How the library stays hydration-safe under SSR.

The library is built to render the same markup on the server and the client, so it hydrates cleanly under Nuxt SSR with no class-mismatch warnings. This page documents the rules it follows and what they mean if you extend it.

The core rule: no browser APIs at render time

Every browser-API access — window, document, localStorage, matchMedia — is isolated inside onMounted, watchEffect, or an event handler. None of it runs at module top level or synchronously in setup, because that code also runs on the server where those globals do not exist.

The patterns below show how this rule plays out in each composable.

Window width starts as null

useWindowWidth() returns a Ref<number | null> that begins as null — meaning "unknown" — and is only populated once the component mounts on the client. Callers treat the unknown value as desktop so the server always renders the expanded layout deterministically.

import { useWindowWidth } from 'adminlte-vue'

const width = useWindowWidth() // Ref<number | null>, null until mounted
AspectBehaviour
Return type`Ref<number
Initial valuenull (unknown — treated as desktop by callers)
On mountSet to window.innerWidth; a resize listener is attached (passive, rAF-throttled)
On unmountThe listener is removed

provideSidebar consumes this and computes the mobile breakpoint defensively:

// Treat unknown width (SSR / pre-mount) as desktop.
const isMobile = computed(() => (windowWidth.value ?? 9999) <= sidebarBreakpoint)

Because isMobile is false on the server, the push-menu logic renders the desktop layout on both sides of hydration.

Body classes are toggled from a watchEffect

provideSidebar reflects its reactive state onto document.body — not onto an element inside the component tree. The <body> element lives outside the Vue app, so mutating its classList imperatively is hydration-safe: Vue never diffs <body>, so there is no class-mismatch warning.

// Reflect state onto <body>. The body element lives outside the Vue app tree,
// so imperative classList mutation is hydration-safe. No-ops on the server.
watchEffect(() => {
  if (typeof document === 'undefined') return
  const body = document.body
  const dynamic = {
    'sidebar-collapse': isCollapsed.value,
    'sidebar-open': isMobileOpen.value,
    'sidebar-mini': isMiniMode.value,
  }
  for (const [cls, on] of Object.entries(dynamic)) body.classList.toggle(cls, on)
  // staticBodyClasses are added here too.
})

The typeof document === 'undefined' guard makes the effect a no-op on the server, and watchEffect only runs its first pass on the client during hydration.

provideSidebar options

NameTypeDefaultDescription
sidebarMinibooleanfalseStart in mini (icon-only) mode → body.sidebar-mini.
enablePersistencebooleanfalsePersist the collapse state under lte.sidebar.state. Reads/writes happen in onMounted/watch, never on the server.
sidebarBreakpointnumber992Viewport width (px) below which the sidebar behaves as a mobile overlay.
staticBodyClassesMaybeRefOrGetter<string>Static layout classes (e.g. layout-fixed sidebar-expand-lg) added to <body> alongside the dynamic state classes.

Persistence is opt-in for a reason: the saved collapse state can differ from the server-rendered default, so it is read in onMounted (after first paint) to avoid driving a hydration mismatch.

Color mode avoids the flash with a head script

Color mode writes data-bs-theme on <html> and persists the preference under the lte-theme localStorage key. The persisted value cannot influence the server render, so @adminlte/nuxt injects a blocking inline head script (the themeScript option) that sets the attribute before first paint. useColorMode only owns reactive updates after hydration. In the demo, the toggle glyph is rendered under <ClientOnly>.

The dynamic-import plugin pattern

Heavy third-party libs (ApexCharts, Tabulator, Quill, FullCalendar, …) are never statically imported — a static import would pull browser-only code into the server bundle. Each wrapper in src/plugins/*.vue follows the same shape (LteApexChart.vue is the reference implementation):

  • await import(...) the lib inside onMounted only;
  • guard if (!el.value) return after the await, since the component may unmount before the import resolves;
  • destroy the instance in onBeforeUnmount.

Wrap these components in <ClientOnly> so they never render during SSR, and provide a #fallback for the server pass:

<template>
  <ClientOnly>
    <LteApexChart :options="options" :series="series" />
    <template #fallback>
      <div class="placeholder-glow"><span class="placeholder col-12" /></div>
    </template>
  </ClientOnly>
</template>

Consumers install the matching lib as their own dependency and load its CSS.

Checklist for new code

  • Touch window / document / localStorage / matchMedia only inside onMounted, watchEffect (with a typeof … === 'undefined' guard), or an event handler.
  • Default any value derived from a browser API to a server-safe constant (the way window width defaults to desktop).
  • Mutate only elements outside the app tree (like <body>) imperatively; never imperatively change rendered DOM that Vue will diff.
  • Wrap any component that loads a browser-only lib in <ClientOnly> with a #fallback.
  • Verify with pnpm build:demo (and node apps/demo/.output/server/index.mjs) — a clean production demo build with no hydration warnings is the real gate.