Server-Side Rendering

Hollows UI works seamlessly with SSR and SSG frameworks with zero hydration mismatches.

Overview

Hollows UI is designed for server-first architectures. The empty state output is deterministic -- the same props always produce the same HTML. This means SSR, SSG, and ISR all work out of the box without any special configuration.

SSR compatibility matrix

FrameworkSSRSSGStreaming
Next.js (App Router)
Next.js (Pages Router)--
Nuxt 3
SvelteKit
Astro--

Why No Hydration Mismatch

Hydration mismatches happen when the server renders different HTML than the client expects. Hollows avoids this because:

  • 1.Deterministic output. The empty state HTML is derived purely from props and the static registry. No randomness, no Date.now(), no browser-only APIs.
  • 2.Inline SVGs, not images. Illustrations are rendered as inline SVG elements, not fetched from URLs. The same SVG string renders identically on server and client.
  • 3.CSS-only responsive behavior. Layout changes are handled via CSS media/container queries, not JavaScript resize listeners that produce different initial values on server vs client.
  • 4.No useEffect for rendering. The Hollow component renders synchronously. There is no client-side-only effect that changes the initial output.

Next.js

Hollows works with both the App Router and Pages Router. With the App Router, the Hollow component can be used in Server Components directly.

App Router (Server Component)

app/inbox/page.tsx
tsx
import { Hollow } from 'hollows-ui/react'import { getMessages } from '@/lib/api'// This is a Server Component — no 'use client' neededexport default async function InboxPage() {  const messages = await getMessages()  return (    <Hollow name="user-inbox" empty={messages.length === 0}>      <MessageList messages={messages} />    </Hollow>  )}

App Router (Client Component)

components/inbox.tsx
tsx
'use client'import { Hollow } from 'hollows-ui/react'import { useMessages } from '@/hooks/use-messages'export function Inbox() {  const { data, isLoading } = useMessages()  return (    <Hollow name="user-inbox" empty={!data || data.length === 0} loading={isLoading}>      {data && <MessageList messages={data} />}    </Hollow>  )}

Pages Router

pages/inbox.tsx
tsx
import { Hollow } from 'hollows-ui/react'export async function getServerSideProps() {  const messages = await fetchMessages()  return { props: { messages } }}export default function InboxPage({ messages }) {  return (    <Hollow name="user-inbox" empty={messages.length === 0}>      <MessageList messages={messages} />    </Hollow>  )}

Nuxt

Use the Vue adapter with Nuxt 3. The component renders identically on server and client.

pages/inbox.vue
vue
<script setup>import { Hollow } from 'hollows-ui/vue'const { data: messages } = await useFetch('/api/messages')</script><template>  <Hollow name="user-inbox" :empty="!messages?.length">    <MessageList :messages="messages" />  </Hollow></template>

For Nuxt, register the Hollows plugin to load the registry automatically:

plugins/hollows.ts
ts
import { defineNuxtPlugin } from '#app'import { loadRegistry } from 'hollows-ui/vue'import registry from '~/hollows/registry.json'export default defineNuxtPlugin(() => {  loadRegistry(registry)})

SvelteKit

The Svelte adapter works with SvelteKit's load functions and SSR mode.

src/routes/inbox/+page.svelte
svelte
<script>  import { Hollow } from 'hollows-ui/svelte'  export let data</script><Hollow name="user-inbox" empty={!data.messages.length}>  <MessageList messages={data.messages} /></Hollow>
src/routes/inbox/+page.server.ts
ts
import type { PageServerLoad } from './$types'export const load: PageServerLoad = async () => {  const messages = await fetchMessages()  return { messages }}

Streaming SSR

Hollows works with React 18 streaming and Suspense. Wrap your data-fetching component in Suspense, and use the Hollow component inside.

app/inbox/page.tsx
tsx
import { Suspense } from 'react'import { Hollow } from 'hollows-ui/react'export default function InboxPage() {  return (    <Suspense fallback={<InboxSkeleton />}>      <InboxContent />    </Suspense>  )}async function InboxContent() {  const messages = await getMessages()  return (    <Hollow name="user-inbox" empty={messages.length === 0}>      <MessageList messages={messages} />    </Hollow>  )}

The empty state streams as part of the Suspense resolution. There is no flash of content -- the user sees either the skeleton or the final state (content or empty).

Static Generation

For statically generated pages where the empty/non-empty state is known at build time, Hollows renders the correct output during static generation. The client-side JavaScript only needs to handle dynamic transitions.

app/blog/page.tsx
tsx
import { Hollow } from 'hollows-ui/react'// Static generation — empty state is baked into the HTMLexport default async function BlogPage() {  const posts = await getPosts()  return (    <Hollow name="blog-posts" empty={posts.length === 0}>      <PostGrid posts={posts} />    </Hollow>  )}// Revalidate every 60 secondsexport const revalidate = 60