Ghostly
Guides

Suspense Integration

Using Ghostly with React Suspense, RSC, and streaming SSR.

The easiest way to use Ghostly with Suspense. No loading state to manage.

import { GhostlySuspense } from '@ghostly-ui/react'

<GhostlySuspense fallback={<ProductCard />}>
  <AsyncProductCard />
</GhostlySuspense>

GhostlySuspense wraps React's <Suspense> and automatically renders your fallback inside <Ghostly loading={true}> as the Suspense boundary fallback.

This is the recommended approach for any project using React Server Components, the use() hook, or data-fetching libraries with Suspense support.

With options

<GhostlySuspense
  fallback={<UserProfile />}
  animation="pulse"
  radius="lg"
  color="hsl(260 30% 88%)"
  smooth
>
  <AsyncUserProfile userId={123} />
</GhostlySuspense>

Multiple boundaries

<div className="grid grid-cols-12 gap-6">
  <div className="col-span-8">
    <GhostlySuspense fallback={<Chart />} animation="shimmer">
      <AsyncChart />
    </GhostlySuspense>
  </div>
  <div className="col-span-4">
    <GhostlySuspense fallback={<Feed />} animation="wave">
      <AsyncFeed />
    </GhostlySuspense>
  </div>
</div>

Manual Suspense fallback

If you need more control, use <Ghostly> directly as a Suspense fallback:

import { Suspense } from 'react'
import { Ghostly } from '@ghostly-ui/react'

<Suspense fallback={
  <Ghostly loading={true}>
    <ProductCard />
  </Ghostly>
}>
  <AsyncProductCard />
</Suspense>

Next.js loading.tsx

In Next.js App Router, loading.tsx files are automatic Suspense boundaries. There's no children — the file is the fallback. Use <Ghostly loading={true}> with your component rendered empty:

Single page skeleton

app/dashboard/loading.tsx
import { Ghostly } from '@ghostly-ui/react'
import { DashboardPage } from '@/components/dashboard'

export default function Loading() {
  return (
    <Ghostly loading={true}>
      <DashboardPage />
    </Ghostly>
  )
}

The component renders without data (all props undefined), and Ghostly turns it into a skeleton. When Next.js resolves the route, it replaces loading.tsx entirely with page.tsx.

List skeleton

app/products/loading.tsx
import { GhostlyList } from '@ghostly-ui/react'
import { ProductCard } from '@/components/product-card'

export default function Loading() {
  return (
    <GhostlyList loading={true} count={6} item={<ProductCard />} className="grid grid-cols-3 gap-4">
      <></>
    </GhostlyList>
  )
}

Multiple sections

app/dashboard/loading.tsx
import { Ghostly, GhostlyList } from '@ghostly-ui/react'
import { StatsRow } from '@/components/stats-row'
import { OrderRow } from '@/components/order-row'

export default function Loading() {
  return (
    <div className="space-y-8">
      <Ghostly loading={true}>
        <StatsRow />
      </Ghostly>
      <GhostlyList loading={true} count={5} item={<OrderRow />} className="divide-y">
        <></>
      </GhostlyList>
    </div>
  )
}

Your components should handle undefined props gracefully (e.g. data?.title ?? ''). This is standard React practice and makes them work with Ghostly, Suspense, and loading states.

Why it works with streaming SSR

Ghostly's CSS-first approach is perfect for streaming:

  1. CSS loads once in <head>
  2. Streamed boundaries arrive with data-ghostly attribute
  3. When data resolves, the attribute is removed
  4. No hydration mismatch — attribute is set server-side