404 Not Found

404 Not Found


nginx

Streaming & Suspense

Lazy rendering eliminates the need for users to stare at a blank screen while waiting—the page is displayed as it is being generated, reducing the time to first byte (TTFB) by 60%.

1. What You'll Learn


2. A True Story of a Front-End Architect

(1) Pain Point: The waterfall of requests makes the page load three times slower

Charlie is a front-end architect on the TaskFlow team. He noticed that the Dashboard page takes 4.2 seconds to load:

"The page contains 5 data cards, 1 activity list, and 1 statistical chart. All data is loaded sequentially await within the page components; a single slow API call can bring the entire page to a standstill—leaving users staring at a blank screen, waiting idly."

Issue Duration Cause
User list (200 ms) + Number of items (300 ms) 500 ms Serial wait
Statistical Charts (800 ms) 800 ms Slow backend processing
Activity Flow (400 ms) 400 ms Cross-Service Queries
Total TTFP 1,700 ms All serial

(2) The Streaming + Suspense Solution

Split the page into multiple Suspense boundaries, with each data block loaded independently via streaming.

TSX
export default function DashboardPage() {
  return (
    <div>
      <h1>Overview</h1>
      <Suspense fallback={<SkeletonCards />}>
        <UserCards />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  )
}

(3) Revenue

Dimension Before Optimization (Serial) After Optimization (Streaming)
First Byte Time 1,700 ms 50 ms (static shell)
First-screen interactivity 4.2s 1.1s
Large-block blocking All ✅ None
User Experience Blank screen for 4 seconds Skeleton screen → Fills in block by block

3. Suspense Boundary Splitting Strategy

(1) The Three Loading Layers of a Page

100%
graph TB
    A[Page] --> B[Layer 1: Static shell<br/>layout + header<br/>Instant Preview]
    A --> C[Layer 2: Wireframe Display<br/>loading.tsx<br/>~200ms]
    A --> D[Layer 3: Content<br/>Suspense Boundary<br/>Block-by-Block Streaming Arrival]

    B --> E[Users see the page structure]
    C --> F[Users see a placeholder animation]
    D --> G[Fill in the content in order]

    style B fill:#d4edda
    style C fill:#fff3cd
    style D fill:#cce5ff
Layer Mechanism Display Time User Perception
Static Shell Layout Real-time Page Framework
Skeleton Screen loading.tsx ~200 ms Loading animation
Content Suspense + fallback Block-by-block rendering Progressive loading

(2) Serial vs. Parallel Data Loading

TSX
// ❌ Serial Waterfall Chart — Slow
export default async function SlowPage() {
  const users = await fetch('https://api.example.com/users').then(r => r.json())
  const projects = await fetch('https://api.example.com/projects').then(r => r.json())
  const analytics = await fetch('https://api.example.com/analytics').then(r => r.json())
  return <Dashboard users={users} projects={projects} analytics={analytics} />
}

// ✅ Parallel Suspense — Fast
export default function FastPage() {
  return (
    <div>
      <Suspense fallback={<SkeletonCards />}><UserCards /></Suspense>
      <Suspense fallback={<SkeletonCards />}><ProjectCards /></Suspense>
      <Suspense fallback={<ChartSkeleton />}><AnalyticsChart /></Suspense>
    </div>
  )
}

▶ Example: Implementing the Suspense component

Output:

TEXT
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }> | }>
TSX
// components/UserCards.tsx — Independent Suspense Boundary
export default async function UserCards() {
  // Simulating Slow Queries
  const users = await new Promise<{ name: string; email: string }[]>((resolve) =>
    setTimeout(() => resolve([
      { name: 'Alice', email: 'alice@taskflow.io' },
      { name: 'Bob', email: 'bob@taskflow.io' },
      { name: 'Charlie', email: 'charlie@taskflow.io' }
    ]), 2000)
  )

  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      {users.map((u) => (
        <div key={u.email} style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: 8 }}>
          <h3>{u.name}</h3>
          <p>{u.email}</p>
        </div>
      ))}
    </div>
  )
}

Output:

TEXT
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
TSX
// components/SkeletonCards.tsx — Wireframe Display fallback
export default function SkeletonCards() {
  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      {[1, 2, 3].map((i) => (
        <div key={i} style={{
          width: 200, height: 100, borderRadius: 8,
          background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
          backgroundSize: '200% 100%',
          animation: 'shimmer 1.5s infinite'
        }} />
      ))}
    </div>
  )
}

4. loading.tsx and Automatic Suspense

(1) Document Provisions

File Purpose Suspense Scope
app/dashboard/loading.tsx Wrap the entire page route page.tsx All content
app/dashboard/settings/loading.tsx settings segment only settings/page.tsx
100%
graph TB
    A[app/dashboard/] --> B[layout.tsx<br/>Root Layout]
    A --> C[loading.tsx<br/>Page-level Suspense]
    A --> D[page.tsx<br/>Page Content]
    D --> E{Within the page}
    E --> F[<Suspense><SlowWidget/></Suspense>]
    E --> G[<Suspense><AnotherWidget/></Suspense>]

    C --> H[Display loading.tsx fallback]
    D --> I[Display page.tsx Content]
    F --> J[Independent Streaming Loading]

    style C fill:#fff3cd
    style F fill:#cce5ff
    style G fill:#cce5ff

(2) Design of loading.tsx

TSX
// app/dashboard/loading.tsx — Page-Level Skeleton Screen
export default function DashboardLoading() {
  return (
    <div style={{ padding: '2rem' }}>
      <div style={{ height: 32, width: 200, background: '#eee', borderRadius: 4, marginBottom: '2rem' }} />
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
        {[1, 2, 3].map((i) => (
          <div key={i} style={{ height: 120, background: '#f5f5f5', borderRadius: 8 }} />
        ))}
      </div>
      <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8, marginTop: '2rem' }} />
    </div>
  )
}

▶ Example: Nested loading.tsx hierarchy

TEXT
app/dashboard/              ← loading.tsx Full-page
├── layout.tsx               ← Navigation Bar(Instant Preview)
├── loading.tsx              ← Page Skeleton Screen
├── page.tsx                 ← Dashboard Content
├── projects/                ← Subroutine
│   ├── loading.tsx          ← Skeleton screen for the project list only
│   └── page.tsx             ← Project List
└── settings/
    └── loading.tsx          ← Set-up Skeleton Screen
TSX
// app/dashboard/projects/loading.tsx — Loading state for the project list only
export default function ProjectsLoading() {
  return (
    <div>
      {[1, 2, 3, 4].map((i) => (
        <div key={i} style={{
          height: 64, marginBottom: 8, borderRadius: 6,
          background: 'linear-gradient(90deg, #e8e8e8 0%, #f5f5f5 50%, #e8e8e8 100%)',
          backgroundSize: '200% 100%',
          animation: 'shimmer 1.5s ease-in-out infinite'
        }} />
      ))}
    </div>
  )
}

5. Designing Nested Suspense and Fallback

(1) Nested Strategies

100%
graph TB
    A[Page] --> B[Outer layer Suspense<br/>fallback: Page Structure]
    A --> C[Inner layer Suspense 1<br/>fallback: Card Template]
    A --> D[Inner layer Suspense 2<br/>fallback: Chart Framework]
    D --> E[At a deeper level Suspense<br/>fallback: Micro-framework]

    B -->|View Now| F[Page Structure]
    C -->|~500ms| G[User Card]
    D -->|~800ms| H[Statistical Charts]
    E -->|~1200ms| I[Chart Details]

    style B fill:#f8d7da
    style C fill:#fff3cd
    style D fill:#cce5ff
    style E fill:#d4edda
Nesting Level Recommended Fallback Display Time Information Density
Outer Layer (Page) Large Placeholder Instant Low (Structure)
Middle Layer (Component) Component Shape Skeleton ~500 ms Middle (Outline)
Inner Layer (Details) Small Placeholder + Micro-animation ~1200 ms Top (Content)

▶ Example: Three-level nested Suspense

Output:

TEXT
Diagram of nested Suspense: outer fallback (page shell) + inner fallback (section loading).
TSX
// app/analytics/page.tsx — Nested Suspense Practical Application
import { Suspense } from 'react'

function SummarySkeleton() { return <div style={{ height: 100, background: '#eee' }} /> }
function ChartSkeleton() { return <div style={{ height: 300, background: '#f5f5f5' }} /> }
function DetailSkeleton() { return <div style={{ height: 60, background: '#fafafa' }} /> }

export default function AnalyticsPage() {
  return (
    <div>
      <h1>Analysis Report</h1>

      {/* Outer layer:Overview Card */}
      <Suspense fallback={<SummarySkeleton />}>
        <SummaryCards />
      </Suspense>

      {/* Middle Management:Charts */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      {/* Inner layer:List of Details */}
      <Suspense fallback={<DetailSkeleton />}>
        <TopProjects />
      </Suspense>
    </div>
  )
}

async function SummaryCards() {
  await new Promise((r) => setTimeout(r, 500))
  return <div>Income for This Month: $120,000 • Number of users: 15,230 • Project: 342</div>
}

async function RevenueChart() {
  await new Promise((r) => setTimeout(r, 1000))
  return <div style={{ height: 300, background: '#e8f4f8' }}>[Charts: Monthly Revenue Trends]</div>
}

async function TopProjects() {
  await new Promise((r) => setTimeout(r, 1500))
  return <div>Top Project: TaskFlow (45%), WebApp (30%), Mobile (25%)</div>
}

Output:

TEXT
Renders static shell immediately. Dynamic sections show "Loading..." fallback until data loads.
Visible content: Analysis Report | }> | }>

6. React 19 use() Hook for Reading Promises

Comparison of use() vs await

Feature await (Server Component) use() (Client Component)
Location Server Component only Client Component (including 'use client')
Blocking behavior Blocking component rendering Throwing a Promise → Suspense catches it
Type Signature const data = await promise const data = use(promise)
Resubmit Automatic (RSC re-execution) Must be triggered manually
TSX
// ✅ Server Component: await
async function ServerProfile({ id }: { id: string }) {
  const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json())
  return <div>{user.name}</div>
}

// ✅ Client Component: use()
'use client'
import { use } from 'react'

function ClientProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise)
  return <div>{user.name}</div>
}

(2) use() Using Streaming in the Client Component

TSX
// components/StreamingProfile.tsx — use() + Suspense
'use client'
import { use } from 'react'

interface User { name: string; email: string; bio: string }

function UserProfile({ promise }: { promise: Promise<User> }) {
  const user = use(promise)
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.bio}</p>
    </div>
  )
}

// Use on the page
import { Suspense } from 'react'

export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params) // params Me too. Promise
  const userPromise = fetch(`https://api.example.com/users/${id}`).then(r => r.json())

  return (
    <Suspense fallback={<div>Loading user profile...</div>}>
      <UserProfile promise={userPromise} />
    </Suspense>
  )
}
💡 Tip: In Next.js 16, both params and searchParams are Promises; you must unwrap them using await (RSC) or use() (Client).

▶ Example: use() Implementing an infinite scroll feed

Output:

TEXT
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
TSX
// components/InfinitePosts.tsx
'use client'
import { use, useState, useTransition } from 'react'

interface Post { id: number; title: string }

async function fetchPosts(page: number): Promise<Post[]> {
  const res = await fetch(`/api/posts?page=${page}&limit=10`)
  return res.json()
}

export default function InfinitePosts({ initialPromise }: { initialPromise: Promise<Post[]> }) {
  const [page, setPage] = useState(1)
  const [postsPromise, setPostsPromise] = useState(initialPromise)
  const [isPending, startTransition] = useTransition()

  const posts = use(postsPromise)

  const loadMore = () => {
    startTransition(() => {
      setPage((p) => p + 1)
      setPostsPromise(fetchPosts(page + 1))
    })
  }

  return (
    <div>
      {posts.map((post) => <div key={post.id}>{post.title}</div>)}
      <button onClick={loadMore} disabled={isPending}>
        {isPending ? 'Loading......' : 'Load More'}
      </button>
    </div>
  )
}

Output:

TEXT
Renders: InfinitePosts page with interactive UI elements.

7. AI SDK StreamText Integration

(1) streamText Streaming Architecture

100%
graph LR
    A[User Messages] --> B[Route Handler<br/>POST /api/chat]
    B --> C[AI SDK streamText]
    C --> D[LLM Provider<br/>OpenAI / Anthropic]
    D -->|Flow Token| E[ReadableStream]
    E --> F[Client Component<br/>useChat Hook]
    F --> G[Typewriter Effect]

    style B fill:#cce5ff
    style C fill:#d4edda
    style F fill:#fff3cd
Component Function Installation
ai Core Library streamText Functions npm install ai
@ai-sdk/openai OpenAI Provider npm install @ai-sdk/openai
useChat Client Hook Included in the ai package

(2) Server-Side Streaming Routing

TS
// app/api/chat/route.ts — AI Live Chat API
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function POST(req: Request) {
  const { messages } = await req.json()

  const result = streamText({
    model: openai('gpt-4o'),
    system: 'You are TaskFlow\'s AI Assistant. Answer questions related to project management.',
    messages
  })

  return result.toDataStreamResponse()
}

▶ Example: Typewriter effect on the client side

Output:

TEXT
TypeScript code executed successfully.
TSX
// components/ChatBox.tsx
'use client'
import { useChat } from 'ai/react'

export default function ChatBox() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()

  return (
    <div style={{ maxWidth: 600, margin: '0 auto' }}>
      <div style={{ height: 400, overflowY: 'auto', border: '1px solid #ccc', padding: '1rem' }}>
        {messages.map((m) => (
          <div key={m.id} style={{
            textAlign: m.role === 'user' ? 'right' : 'left',
            marginBottom: '1rem'
          }}>
            <strong>{m.role === 'user' ? 'You' : 'AI'}:</strong>
            <p>{m.content}</p>
          </div>
        ))}
        {isLoading && <p>AI Typing......</p>}
      </div>

      <form onSubmit={handleSubmit} style={{ display: 'flex', marginTop: '1rem' }}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Enter your question..."
          style={{ flex: 1, padding: '0.5rem' }}
        />
        <button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem' }}>
          Send
        </button>
      </form>
    </div>
  )
}

Output:

TEXT
Renders a list by mapping over messages, displaying each m.
🔥 Common Mistake: useChat The default is POST /api/chat. To specify a custom API endpoint, pass the api option: useChat({ api: '/api/ai/chat' }).


8. Complete Example: AI Analytics Dashboard + Streaming Data

TSX
// app/dashboard/page.tsx — Flow Dashboard + AI Analysis
import { Suspense } from 'react'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

// Frame Display Modules
function MetricSkeleton() {
  return <div style={{ height: 100, background: '#f0f0f0', borderRadius: 8 }} />
}

function ChartSkeleton() {
  return <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8 }} />
}

// Slow Data Component
async function TeamMetrics() {
  const metrics = await new Promise<{ members: number; projects: number; tasks: number }>(
    (resolve) => setTimeout(() => resolve({ members: 12, projects: 45, tasks: 230 }), 1500)
  )
  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      <div>👥 {metrics.members} Members</div>
      <div>📁 {metrics.projects} Project</div>
      <div>✅ {metrics.tasks} Task</div>
    </div>
  )
}

async function ActivityChart() {
  const data = await new Promise<number[]>((r) => setTimeout(() => r([30, 45, 78, 92, 55, 88, 120]), 2000))
  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.5rem', height: 200 }}>
      {data.map((v, i) => (
        <div key={i} style={{ height: v, width: 40, background: '#4f46e5', borderRadius: '4px 4px 0 0' }} />
      ))}
    </div>
  )
}

export default async function DashboardPage() {
  const session = await auth()
  if (!session) redirect('/login')

  return (
    <div>
      <h1>TaskFlow Overview</h1>
      <p>Welcome back,{session.user!.name}</p>

      <Suspense fallback={<MetricSkeleton />}>
        <TeamMetrics />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <ActivityChart />
      </Suspense>
    </div>
  )
}
TS
// app/api/chat/route.ts — AI Analysis Assistant
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function POST(req: Request) {
  const { messages } = await req.json()

  const result = streamText({
    model: openai('gpt-4o-mini'),
    system: 'You are a project management AI Assistant. Based on the project data provided, provide an analysis and recommendations. Keep your answers brief.',
    messages
  })

  return result.toDataStreamResponse()
}
TSX
// components/ChatPanel.tsx
'use client'
import { useChat } from 'ai/react'

export default function ChatPanel() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()

  return (
    <div style={{ position: 'fixed', bottom: 0, right: 20, width: 380, border: '1px solid #ccc', borderRadius: '8px 8px 0 0' }}>
      <div style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', borderRadius: '8px 8px 0 0' }}>
        AI Analysis Assistant
      </div>
      <div style={{ height: 300, overflowY: 'auto', padding: '0.5rem' }}>
        {messages.map((m) => (
          <div key={m.id} style={{ marginBottom: '0.5rem' }}>
            <strong>{m.role === 'user' ? 'Me' : 'AI'}:</strong>
            <p style={{ margin: 0 }}>{m.content}</p>
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit} style={{ display: 'flex', borderTop: '1px solid #eee' }}>
        <input value={input} onChange={handleInputChange} placeholder="Ask a Question About the Project..." style={{ flex: 1, padding: '0.5rem', border: 'none' }} />
        <button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', border: 'none' }}>Send</button>
      </form>
    </div>
  )
}
💻 Effect Description: The page first displays a skeleton view → After 0.5 seconds, the metrics cards populate → After 1 second, the bar chart appears → The AI panel appears on the right for real-time conversation, with content displayed in a word-by-word stream.


❓ FAQ

Q What is the difference between Suspense and loading.tsx?
A loading.tsx is a file convention that automatically creates a Suspense boundary for the entire page, making implementation simple. <Suspense> components are used for fine-grained control within a page; they can wrap any number of independent data blocks for parallel streaming.
Q Does stream rendering affect SEO?
A No. Search engine crawlers (Googlebot) wait until the final HTML is complete before indexing it; streamed content is loaded progressively rather than injected later. Next.js’s RSC Payload ensures that crawlers see the complete content.
Q When is use() better than await?
A use() can be used in client components, allowing the component itself to declare its dependency on a Promise, with the nearest Suspense boundary handling the loading state. It is suitable for scenarios where asynchronous operations need to be triggered on the front end (such as clicking “Load More”).
Q What is the difference between using the AI SDK’s streamText method and calling the OpenAI API directly?
A streamText automatically handles the SSE (Server-Sent Events) protocol, backpressure control, token counting, and error retries. Calling the OpenAI API directly requires manual handling of ReadableStream and the response format.
Q How do streaming rendering and PPR (Partial Prerendering) work together?
A The static shell in PPR consists of the outer layout and static content, while the internal dynamic parts are wrapped in <Suspense>. PPR pre-generates the static parts, and the dynamic parts are rendered on the fly—the two complement each other perfectly.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Implement three Suspense boundaries on a single page to load the user list, project statistics, and activity log, respectively, with each boundary having a different delay (500 ms / 1000 ms / 1500 ms).

  2. Advanced Exercise (⭐⭐): Use the AI SDK’s streamText to create a translation assistant API route, and use useChat on the client side to implement a typewriter effect that displays the translation results one character at a time.

  3. Challenge (⭐⭐⭐): Implement a multi-level nested dashboard page: The outer layer loading.tsx displays the full-page skeleton view, with three nested layers of Suspense inside the page (statistics card → chart → detailed list). The outermost layer uses the use() hook to load more data when clicked.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏