SSR Compatibility

SSR Compatibility

Valaxy builds your site using SSG (Static Site Generation), which renders pages to HTML at build time via Vue’s server-side rendering (SSR). This means components run in a Node.js environment during the build, where browser APIs like window, document, and navigator are not available.

Upgrading from the old `vite-ssg` engine

Valaxy used to ship a JSDOM-based vite-ssg engine, removed in v1.0 (see #706). JSDOM silently provided window, document, and navigator during SSR, so code that touched these globals at render time appeared to "work". The Valaxy SSG engine renders pure strings with no DOM, so the same code now throws or hydrates incorrectly.

If you are upgrading and a theme/addon relied on a DOM during SSR, guard every browser-only access with the patterns below.

Why Hydration Mismatches Happen

After SSG generates static HTML, Vue "hydrates" it in the browser — attaching event listeners and making it interactive. If the HTML rendered on the server differs from what the client renders, you get a hydration mismatch warning.

Common causes:

CauseExample
Browser-only API in template{{ window.innerWidth }}
Time/locale-dependent values{{ new Date().toLocaleString() }}
Browser extensions modifying HTMLAd blockers injecting elements
Non-standard HTML nesting<p> inside <p>, <div> inside <a>

<ClientOnly>

Wrap browser-only content with the built-in <ClientOnly> component. Its content is only rendered on the client side.

vue
<template>
  <ClientOnly>
    <BrowserOnlyComponent />
  </ClientOnly>
</template>

Use the #fallback slot to show placeholder content during SSR/SSG:

vue
<template>
  <ClientOnly>
    <HeavyChart :data="chartData" />
    <template #fallback>
      <div class="chart-placeholder">
        Loading chart...
      </div>
    </template>
  </ClientOnly>
</template>

defineClientComponent

For third-party libraries that access browser APIs at import time (not just at render time), use defineClientComponent. It delays the import() until the component mounts in the browser.

vue
<script setup>
import { defineClientComponent } from 'valaxy'

const MyBrowserLib = defineClientComponent(
  () => import('some-browser-only-lib')
)
</script>

<template>
  <MyBrowserLib />
</template>

You can pass props and a callback:

vue
<script setup>
import { defineClientComponent } from 'valaxy'

const EchartsChart = defineClientComponent(
  () => import('vue-echarts'),
  [
    { option: chartOption, autoresize: true }, // props
    { default: () => h('div', 'Loading...') }, // children/slots
  ],
  (mod) => {
    // called after the module is loaded
    console.log('vue-echarts loaded', mod)
  },
)
</script>

<template>
  <EchartsChart />
</template>

onMounted + ref Pattern

For simple cases where you need browser APIs in logic (not in third-party imports), use Vue’s onMounted:

vue
<script setup>
import { onMounted, ref } from 'vue'

const screenWidth = ref(0)

onMounted(() => {
  screenWidth.value = window.innerWidth
})
</script>

<template>
  <p>Screen width: {{ screenWidth }}</p>
</template>

import.meta.env.SSR

Use the import.meta.env.SSR flag (provided by Vite) to conditionally execute code:

ts
if (!import.meta.env.SSR) {
  // This code only runs in the browser
  document.addEventListener('scroll', handleScroll)
}

This is useful in composables or setup functions where you need to guard browser-only side effects.

CSS-Based Responsive Rendering

Avoid using v-if with reactive viewport values for responsive layouts — this causes hydration mismatches because the server cannot know the viewport size. Use CSS instead:

vue
<!-- Bad: causes hydration mismatch -->
<template>
  <MobileNav v-if="isMobile" />
  <DesktopNav v-else />
</template>

<!-- Good: use CSS media queries -->
<template>
  <MobileNav class="mobile-only" />
  <DesktopNav class="desktop-only" />
</template>

<style>
.mobile-only {
  display: block;
}
.desktop-only {
  display: none;
}
@media (min-width: 768px) {
  .mobile-only {
    display: none;
  }
  .desktop-only {
    display: block;
  }
}
</style>

Tips for Theme & Addon Developers

  • Always test with pnpm demo:build (SSG build) — pnpm demo (dev mode) won’t catch SSR issues.
  • Wrap all browser-only third-party components with <ClientOnly> or defineClientComponent.
  • Never access window, document, or navigator at the top level of a <script setup> block — move it into onMounted.
  • If a library provides a server-safe build (e.g., import lib from 'lib/dist/ssr'), prefer that over wrapping with <ClientOnly>.
  • Use import.meta.env.SSR for conditional side effects in composables.

Teleport overlays

Valaxy provides a dedicated #valaxy-teleports container outside the application root in both development and SSG. Themes and addons should target it for dialogs and menus:

vue
<Teleport to="#valaxy-teleports">
  <div v-if="open" role="dialog">Dialog content</div>
</Teleport>

Pass to="#valaxy-teleports" to Reka UI components such as DropdownMenuPortal and TooltipPortal as well. The framework HTML template owns this container; do not recreate it inside the Vue application. SSG injects rendered portal content and hydration anchors inside it.

Legacy to="body" portals retain compatibility handling, but new code should use the dedicated container to avoid mixing with application DOM. See Vue SSR Teleports. Third-party overlays created only after client mounting do not participate in SSR Teleport hydration.

For production regression checks, run pnpm docs:build followed by pnpm e2e:ssg. These tests use the production preview and cover repeated route changes and overlay opening/closing on desktop and mobile.

Contributors