Skip to main content

Server-Side Rendering (SSR)

Vuetify Zero is fully compatible with server-side rendering. This guide covers SSR considerations, hydration patterns, and performance optimization.

Browser Detection

IN_BROWSER Constant

Use IN_BROWSER to conditionally run browser-only code:
import { IN_BROWSER } from '@vuetify/v0'

// Safe for SSR
if (IN_BROWSER) {
  // Browser-only code
  window.addEventListener('resize', handleResize)
  localStorage.setItem('key', 'value')
}
IN_BROWSER is true when typeof window !== 'undefined'.

Other Browser Constants

import { 
  IN_BROWSER,
  SUPPORTS_TOUCH,
  SUPPORTS_MATCH_MEDIA,
  SUPPORTS_OBSERVER,
  SUPPORTS_INTERSECTION_OBSERVER,
  SUPPORTS_MUTATION_OBSERVER,
} from '@vuetify/v0'

// Feature detection for progressive enhancement
if (SUPPORTS_INTERSECTION_OBSERVER) {
  // Use IntersectionObserver
} else {
  // Fallback behavior
}
Never access window, document, localStorage, or browser APIs at module scope. Always wrap in IN_BROWSER checks.

Hydration Management

useHydration Composable

Track hydration state for SSR-safe rendering:
import { useHydration } from '@vuetify/v0'

const hydration = useHydration()

console.log(hydration.isHydrated.value)  // false during SSR
console.log(hydration.isSettled.value)   // false until first tick post-mount

Hydration States

PropertyDescription
isHydratedtrue after root component mounts (hydration complete)
isSettledtrue after first tick post-hydration (safe for animations)

Installation

Install the hydration plugin:
import { createApp } from 'vue'
import { createHydrationPlugin } from '@vuetify/v0'
import App from './App.vue'

const app = createApp(App)

app.use(createHydrationPlugin())

app.mount('#app')
The plugin automatically detects when the root component mounts and updates hydration state.

Using in Components

<script setup lang="ts">
import { useHydration } from '@vuetify/v0'
import { watch } from 'vue'

const hydration = useHydration()

watch(hydration.isHydrated, (hydrated) => {
  if (hydrated) {
    // Safe to run browser-specific code
    initializeClientSideFeatures()
  }
})
</script>

Lazy Rendering

useLazy Composable

Defer content rendering until first activation:
import { useLazy } from '@vuetify/v0'
import { ref } from 'vue'

const isOpen = ref(false)
const { hasContent, onAfterLeave } = useLazy(isOpen)

Dialog Example

<script setup lang="ts">
import { Dialog } from '@vuetify/v0'
import { useLazy } from '@vuetify/v0'
import { ref } from 'vue'

const isOpen = ref(false)
const { hasContent, onAfterLeave } = useLazy(isOpen)
</script>

<template>
  <Dialog.Root v-model="isOpen">
    <Dialog.Activator>Open Dialog</Dialog.Activator>
    
    <Transition @after-leave="onAfterLeave">
      <Dialog.Content v-if="isOpen">
        <!-- Only renders after first open -->
        <template v-if="hasContent">
          <Dialog.Title>Heavy Content</Dialog.Title>
          <ExpensiveComponent />
        </template>
      </Dialog.Content>
    </Transition>
  </Dialog.Root>
</template>

Eager Mode

Render immediately without waiting:
const { hasContent } = useLazy(isOpen, { eager: true })
// hasContent.value is always true

Benefits

  • Reduced initial bundle: Heavy components only load when needed
  • Faster hydration: Less DOM to hydrate on initial page load
  • Memory savings: Content can be unmounted when inactive
Use useLazy for modals, popovers, tooltips, and any conditionally visible content.

SSR-Safe Components

Using IN_BROWSER in Components

<script setup lang="ts">
import { IN_BROWSER } from '@vuetify/v0'
import { ref, onMounted } from 'vue'

const windowWidth = ref(0)

if (IN_BROWSER) {
  windowWidth.value = window.innerWidth
}

onMounted(() => {
  // Always safe in onMounted
  window.addEventListener('resize', () => {
    windowWidth.value = window.innerWidth
  })
})
</script>

<template>
  <div>Window width: {{ windowWidth }}px</div>
</template>

Client-Only Components

Use Vue’s <ClientOnly> for components that can’t run on server:
<template>
  <div>
    <h1>Server and Client Content</h1>
    
    <ClientOnly>
      <MapComponent />
      <template #fallback>
        <div>Loading map...</div>
      </template>
    </ClientOnly>
  </div>
</template>

Media Queries & Observers

useMediaQuery

SSR-safe media query composable:
import { useMediaQuery } from '@vuetify/v0'
import { computed } from 'vue'

const isMobile = useMediaQuery('(max-width: 768px)')
const isDesktop = useMediaQuery('(min-width: 1024px)')
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

// Returns false during SSR, true/false after hydration
const layout = computed(() => {
  if (isMobile.value) return 'mobile'
  if (isDesktop.value) return 'desktop'
  return 'tablet'
})

useResizeObserver

SSR-safe resize observer:
import { useResizeObserver } from '@vuetify/v0'
import { ref } from 'vue'

const el = ref<HTMLElement>()
const width = ref(0)

useResizeObserver(el, (entries) => {
  width.value = entries[0].contentRect.width
})

useIntersectionObserver

SSR-safe intersection observer:
import { useIntersectionObserver } from '@vuetify/v0'
import { ref } from 'vue'

const el = ref<HTMLElement>()
const isVisible = ref(false)

useIntersectionObserver(el, (entries) => {
  isVisible.value = entries[0].isIntersecting
})
All observers automatically skip during SSR and activate after hydration.

Storage

useStorage Composable

SSR-safe storage with automatic fallback:
import { useStorage } from '@vuetify/v0'

const theme = useStorage('theme', 'light')

// During SSR: uses in-memory storage
// In browser: uses localStorage
// Syncs across tabs automatically

Custom Storage Adapter

import { useStorage, MemoryAdapter } from '@vuetify/v0'

const cache = useStorage('cache', {}, {
  adapter: IN_BROWSER ? window.sessionStorage : new MemoryAdapter(),
})

Complete SSR Example

<script setup lang="ts">
import { Dialog } from '@vuetify/v0'
import { 
  IN_BROWSER,
  useHydration,
  useLazy,
  useMediaQuery,
  useStorage,
} from '@vuetify/v0'
import { ref, computed, watch } from 'vue'

const isOpen = ref(false)
const hydration = useHydration()
const { hasContent, onAfterLeave } = useLazy(isOpen)

// SSR-safe media query
const isMobile = useMediaQuery('(max-width: 768px)')

// SSR-safe storage
const preference = useStorage('dialog-preference', 'default')

// Only initialize after hydration
watch(hydration.isHydrated, (hydrated) => {
  if (hydrated && IN_BROWSER) {
    // Safe to access browser APIs
    console.log('Hydrated!', window.innerWidth)
  }
})

// Layout adapts to screen size
const dialogClass = computed(() => 
  isMobile.value ? 'mobile-dialog' : 'desktop-dialog'
)
</script>

<template>
  <Dialog.Root v-model="isOpen">
    <Dialog.Activator>
      Open Dialog
    </Dialog.Activator>
    
    <Transition @after-leave="onAfterLeave">
      <Dialog.Content v-if="isOpen" :class="dialogClass">
        <!-- Only renders heavy content after first open -->
        <template v-if="hasContent">
          <Dialog.Title>Responsive Dialog</Dialog.Title>
          
          <Dialog.Description>
            This dialog adapts to screen size and only renders
            after first open for better SSR performance.
          </Dialog.Description>
          
          <ExpensiveComponent v-if="hydration.isSettled.value" />
          
          <Dialog.Close>Close</Dialog.Close>
        </template>
      </Dialog.Content>
    </Transition>
  </Dialog.Root>
</template>

<style scoped>
.mobile-dialog {
  /* Mobile styles */
  width: 100%;
  height: 100%;
}

.desktop-dialog {
  /* Desktop styles */
  max-width: 600px;
  border-radius: 8px;
}
</style>

Best Practices

1
Always Check IN_BROWSER
2
Wrap browser API calls:
3
// Good
import { IN_BROWSER } from '@vuetify/v0'

if (IN_BROWSER) {
  localStorage.setItem('key', 'value')
}

// Bad
localStorage.setItem('key', 'value')  // ❌ Crashes during SSR
4
Use onMounted for Browser Code
5
onMounted only runs in browser:
6
import { onMounted } from 'vue'

onMounted(() => {
  // Always safe - only runs in browser
  window.addEventListener('scroll', handleScroll)
})
7
Prefer Composables Over Direct APIs
8
Vuetify Zero composables are SSR-safe:
9
// Good - SSR-safe
import { useMediaQuery } from '@vuetify/v0'
const isMobile = useMediaQuery('(max-width: 768px)')

// Avoid - requires manual SSR handling
const isMobile = ref(window.matchMedia('(max-width: 768px)').matches)
10
Use useLazy for Heavy Content
11
Defer rendering for better performance:
12
import { useLazy } from '@vuetify/v0'

const { hasContent } = useLazy(isVisible)

// Only render when hasContent is true
13
Wait for isSettled Before Animations
14
Delay animations until after hydration:
15
const hydration = useHydration()

watch(hydration.isSettled, (settled) => {
  if (settled) {
    // Safe to start animations
    startAnimations()
  }
})

Troubleshooting

Hydration Mismatches

Problem: Content differs between SSR and client. Solution: Ensure initial state matches:
// Bad - different initial values
const count = ref(IN_BROWSER ? localStorage.getItem('count') : 0)

// Good - consistent initial value
const count = ref(0)
onMounted(() => {
  count.value = localStorage.getItem('count') || 0
})

Window is not defined

Problem: Accessing window during SSR. Solution: Wrap in IN_BROWSER check:
import { IN_BROWSER } from '@vuetify/v0'

const width = IN_BROWSER ? window.innerWidth : 0

Event Listeners Not Working

Problem: Listeners added during SSR don’t work. Solution: Add in onMounted:
import { onMounted } from 'vue'

onMounted(() => {
  window.addEventListener('resize', handleResize)
})

Next Steps

Build docs developers (and LLMs) love