Next.js: Projeto Final: Dashboard & PPR

Última atualização: 2026-08-26

O dashboard é o "cockpit" de uma plataforma SaaS — um framework estático permite que você veja o layout de relance, enquanto fluxos de dados dinâmicos preenchem as métricas-chave em tempo real.

1. O Que Você Vai Aprender



2. Uma História Real de um Arquiteto Front-End

(1) Ponto de Dor: O dashboard leva 8 segundos para carregar e todos os usuários foram embora.

O TaskFlow da Alice recebeu 50 comentários de feedback de usuários durante o teste beta — 92% deles reclamaram da velocidade de carregamento do Dashboard. O problema estava na recuperação sequencial de dados: primeiro recuperando o número de projetos → depois o número de tarefas → depois o número de membros → e, finalmente, o feed de atividades. Essas quatro requisições de API causavam um bloqueio em cascata, levando um total de 8,2 segundos. Charlie (Marketing) disse: "Esperei 10 segundos e simplesmente fechei a página."

(2) Solução com PPR Static Shell + Coleta Paralela de Dados

Use o Static Shell do PPR para renderizar o esqueleto da página instantaneamente e, em seguida, use Promise.all para carregar 4 fontes de dados em paralelo.

TSX
// PPR Static shell + Recuperação Paralela de Dados
export default function DashboardPage() {
  return (
    <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
      <Suspense fallback={<StatCardSkeleton />}>
        <ProjectCountWidget />
      </Suspense>
      <Suspense fallback={<StatCardSkeleton />}>
        <TaskCountWidget />
      </Suspense>
      <Suspense fallback={<StatCardSkeleton />}>
        <MemberCountWidget />
      </Suspense>
      <Suspense fallback={<StatCardSkeleton />}>
        <ActivityFeedWidget />
      </Suspense>
    </div>
  )
}

(3) Resultados

Dimensão Carregamento Serial PPR + Carregamento Paralelo
Tempo de Renderização da Primeira Tela 8,2 segundos 0,3 segundos (shell estático)
Dados carregados 8,2 segundos 2,1 segundos (paralelo)
Experiência do Usuário Tela em Branco → Redirecionamento Skeleton → Carregamento Progressivo
LCP 7,8 segundos 1,2 segundos
Retenção de Usuários 8% de Taxa de Rejeição 2% de Taxa de Rejeição


3. Layout de Navegação Global

(1) Diagrama de Arquitetura do Layout

100%
graph TB
    subgraph "Root Layout"
        A[RootLayout<br/>html/body/fonts]
    end

    subgraph "Dashboard Layout"
        B[Sidebar<br/>Organização<br/>Navegação<br/>Menu do Usuário]
        C[TopNav<br/>Barra de Busca<br/>Notificações<br/>Breadcrumb]
        D[Conteúdo Principal<br/>{children}]
    end

    A --> B
    B --> C
    C --> D

    style A fill:#cce5ff
    style B fill:#d4edda
    style C fill:#ffeeba
    style D fill:#f8d7da

(2) Componentes do Layout do Dashboard

TSX
// src/app/(dashboard)/layout.tsx
import { Sidebar } from "@/components/layout/sidebar"
import { TopNav } from "@/components/layout/top-nav"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()

  if (!session?.user) {
    redirect("/auth/signin")
  }

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar user={session.user} />
      <div className="flex flex-1 flex-col overflow-hidden">
        <TopNav user={session.user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

▶ Exemplo: Componente Sidebar

Saída:

TEXT 📖 Somente leitura
Inclui uma barra lateral.
TSX
// src/components/layout/sidebar.tsx
import Link from "next/link"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { prisma } from "@/lib/db"

interface SidebarProps {
  user: {
    id: string
    name?: string | null
    email?: string | null
    image?: string | null
  }
}

export async function Sidebar({ user }: SidebarProps) {
  const org = await prisma.user.findUnique({
    where: { id: user.id },
    include: {
      organization: {
        include: {
          _count: { select: { projects: true, users: true } },
        },
      },
    },
  })

  const organization = org?.organization
  const initials = user.name
    ?.split(" ")
    .map((n) => n[0])
    .join("")
    .toUpperCase()

  return (
    <aside className="flex w-64 flex-col border-r bg-card">
      <div className="flex h-14 items-center gap-2 border-b px-6">
        <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
          TF
        </div>
        <span className="text-lg font-semibold">TaskFlow</span>
      </div>

      <div className="flex-1 space-y-1 p-4">
        <div className="mb-4 rounded-lg bg-muted p-3">
          <p className="text-sm font-medium">{organization?.name}</p>
          <p className="text-xs text-muted-foreground">
            {organization?._count.projects} projetos · {organization?._count.users} membros
          </p>
        </div>

        <NavItem href="/dashboard" icon="LayoutDashboard" label="Dashboard" />
        <NavItem href="/dashboard/projects" icon="FolderKanban" label="Projetos" />
        <NavItem href="/dashboard/tasks" icon="CheckSquare" label="Tarefas" />
        <NavItem href="/dashboard/team" icon="Users" label="Equipe" />
        <NavItem href="/dashboard/settings" icon="Settings" label="Configurações" />

        <Separator className="my-4" />

        <p className="mb-2 px-3 text-xs font-medium text-muted-foreground">
          CÓDIGO DE CONVITE
        </p>
        <div className="rounded-md border bg-background p-2 text-center font-mono text-sm">
          {organization?.inviteCode}
        </div>
      </div>

      <div className="border-t p-4">
        <div className="flex items-center gap-3">
          <Avatar className="h-8 w-8">
            <AvatarImage src={user.image ?? undefined} />
            <AvatarFallback>{initials || "U"}</AvatarFallback>
          </Avatar>
          <div className="flex-1 truncate">
            <p className="text-sm font-medium">{user.name}</p>
            <p className="text-xs text-muted-foreground truncate">{user.email}</p>
          </div>
        </div>
      </div>
    </aside>
  )
}

function NavItem({
  href,
  icon,
  label,
}: {
  href: string
  icon: string
  label: string
}) {
  const iconMap: Record<string, string> = {
    LayoutDashboard: "\u2302",
    FolderKanban: "\u25A1",
    CheckSquare: "\u2713",
    Users: "\u263A",
    Settings: "\u2699",
  }

  return (
    <Link
      href={href}
      className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
    >
      <span className="text-lg">{iconMap[icon] || "\u2022"}</span>
      {label}
    </Link>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza: TF | TaskFlow | CÓDIGO DE CONVITE

▶ Exemplo: Barra de Navegação Superior

Saída:

TEXT 📖 Somente leitura
O componente renderiza a UI descrita no navegador.
TSX
// src/components/layout/top-nav.tsx
import { Breadcrumb } from "@/components/layout/breadcrumb"

export function TopNav() {
  return (
    <header className="flex h-14 items-center gap-4 border-b bg-background px-6">
      <Breadcrumb />
      <div className="ml-auto flex items-center gap-4">
        <button
          type="button"
          className="relative rounded-full p-1 text-muted-foreground hover:text-foreground"
        >
          <span className="text-lg">\uD83D\uDD14</span>
          <span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
            3
          </span>
        </button>
        <button
          type="button"
          className="rounded-full p-1 text-muted-foreground hover:text-foreground"
        >
          <span className="text-lg">\u2753</span>
        </button>
      </div>
    </header>
  )
}

Saída:

TEXT 📖 Somente leitura
Uma página com uma seção de cabeçalho.
Texto visível: \uD83D\uDD14 | 3 | \u2753

▶ Exemplo: Navegação Breadcrumb

Saída:

TEXT 📖 Somente leitura
A página renderiza conforme descrito acima, com a UI atualizando com base no comportamento descrito.
TSX
// src/components/layout/breadcrumb.tsx
"use client"

import { usePathname } from "next/navigation"
import Link from "next/link"

const routeLabels: Record<string, string> = {
  dashboard: "Dashboard",
  projects: "Projetos",
  tasks: "Tarefas",
  team: "Equipe",
  settings: "Configurações",
}

export function Breadcrumb() {
  const pathname = usePathname()
  const segments = pathname.split("/").filter(Boolean)

  return (
    <nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm">
      <Link href="/dashboard" className="text-muted-foreground hover:text-foreground">
        Início
      </Link>
      {segments.slice(1).map((segment, index) => {
        const href = "/" + segments.slice(0, index + 2).join("/")
        const label = routeLabels[segment] || segment.replace(/-/g, " ")
        const isLast = index === segments.length - 2

        return (
          <span key={segment} className="flex items-center gap-2">
            <span className="text-muted-foreground">/</span>
            {isLast ? (
              <span className="font-medium text-foreground">{label}</span>
            ) : (
              <Link href={href} className="text-muted-foreground hover:text-foreground">
                {label}
              </Link>
            )}
          </span>
        )
      })}
    </nav>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza uma lista dinâmica de itens usando .map().
Conteúdo visível: Início | ) : (


4. Dashboard PPR Static Shell

(1) Como o PPR Funciona

100%
sequenceDiagram
    participant B as Navegador
    participant N as Servidor Next.js
    participant DB as Banco de Dados

    B->>N: GET /dashboard
    N->>B: Shell Estático (HTML + CSS)<br/>Cards skeleton visíveis
    B->>N: Streaming inicia
    N->>DB: Promise.all<br/>(4 consultas paralelas)
    DB-->>N: Resultados
    N->>B: Conteúdo dinâmico preenche skeletons
    Note over B: Aprimoramento progressivo

(2) Habilitar PPR

TYPESCRIPT
// next.config.ts
import type { NextConfig } from "next"

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

export default nextConfig

▶ Exemplo: Implementação PPR na Página do Dashboard

Saída:

TEXT 📖 Somente leitura
Módulo TypeScript executado com sucesso.
TSX
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <Suspense fallback={<div className="h-24 rounded-lg bg-muted animate-pulse" />}>
        <WelcomeBanner />
      </Suspense>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<StatCardSkeleton />}>
          <ProjectCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <MemberCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCompletionWidget />
        </Suspense>
      </div>

      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2">
          <Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
            <ActivityFeedWidget />
          </Suspense>
        </div>
        <div>
          <Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
            <RecentProjectsWidget />
          </Suspense>
        </div>
      </div>
    </div>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza o shell estático imediatamente. As seções dinâmicas mostram o fallback "}>" até que os dados carreguem.
Conteúdo visível: }> | }> | }>

(3) Implementação do Widget de Recuperação de Dados

TSX
// src/components/dashboard/project-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

export async function ProjectCountWidget() {
  const session = await auth()

  const count = await prisma.project.count({
    where: {
      organization: {
        users: { some: { id: session?.user?.id } },
      },
      status: "ACTIVE",
    },
  })

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium">Projetos Ativos</CardTitle>
        <span className="text-2xl">\uD83D\uDCC1</span>
      </CardHeader>
      <CardContent>
        <div className="text-3xl font-bold">{count}</div>
        <p className="text-xs text-muted-foreground">Em toda a sua organização</p>
      </CardContent>
    </Card>
  )
}
TSX
// src/components/dashboard/task-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

export async function TaskCountWidget() {
  const session = await auth()

  const counts = await prisma.task.groupBy({
    by: ["status"],
    where: {
      project: {
        organization: {
          users: { some: { id: session?.user?.id } },
        },
      },
    },
    _count: true,
  })

  const total = counts.reduce((acc, c) => acc + c._count, 0)
  const todo = counts.find((c) => c.status === "TODO")?._count || 0
  const inProgress = counts.find((c) => c.status === "IN_PROGRESS")?._count || 0
  const done = counts.find((c) => c.status === "DONE")?._count || 0

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium">Total de Tarefas</CardTitle>
        <span className="text-2xl">\u2705</span>
      </CardHeader>
      <CardContent>
        <div className="text-3xl font-bold">{total}</div>
        <div className="mt-2 flex gap-2 text-xs text-muted-foreground">
          <span className="text-yellow-500">{todo} a fazer</span>
          <span className="text-blue-500">{inProgress} em andamento</span>
          <span className="text-green-500">{done} concluídas</span>
        </div>
      </CardContent>
    </Card>
  )
}


5. Recuperação Paralela de Dados

(1) Comparação entre Processamento Serial vs. Paralelo

100%
graph LR
    subgraph "Serial (8.2s)"
        A1[Fetch A] --> A2[Fetch B] --> A3[Fetch C] --> A4[Fetch D]
    end

    subgraph "Paralelo (2.1s)"
        B1[Fetch A]
        B2[Fetch B]
        B3[Fetch C]
        B4[Fetch D]
        B1 --> C[Promise.all]
        B2 --> C
        B3 --> C
        B4 --> C
    end

    style A4 fill:#f8d7da
    style C fill:#d4edda

▶ Exemplo: Usando Promise.all para Recuperar Fluxos de Atividades em Paralelo

Saída:

TEXT 📖 Somente leitura
Diagrama de rotas paralelas: @sidebar e @modal renderizam simultaneamente em slots nomeados.
TSX
// src/components/dashboard/activity-feed-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"

export async function ActivityFeedWidget() {
  const session = await auth()

  const [recentTasks, recentComments] = await Promise.all([
    prisma.task.findMany({
      where: {
        project: {
          organization: {
            users: { some: { id: session?.user?.id } },
          },
        },
      },
      orderBy: { createdAt: "desc" },
      take: 10,
      include: {
        creator: { select: { name: true, image: true } },
        project: { select: { name: true } },
      },
    }),
    prisma.comment.findMany({
      where: {
        task: {
          project: {
            organization: {
              users: { some: { id: session?.user?.id } },
            },
          },
        },
      },
      orderBy: { createdAt: "desc" },
      take: 10,
      include: {
        author: { select: { name: true, image: true } },
        task: { select: { title: true } },
      },
    }),
  ])

  const activities = [
    ...recentTasks.map((t) => ({
      id: t.id,
      type: "task_created" as const,
      user: t.creator,
      description: `criou a tarefa "${t.title}"`,
      project: t.project.name,
      time: t.createdAt,
    })),
    ...recentComments.map((c) => ({
      id: c.id,
      type: "comment_added" as const,
      user: c.author,
      description: `comentou em "${c.task.title}"`,
      project: "",
      time: c.createdAt,
    })),
  ]
    .sort((a, b) => b.time.getTime() - a.time.getTime())
    .slice(0, 10)

  return (
    <Card>
      <CardHeader>
        <CardTitle>Feed de Atividades</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          {activities.map((activity) => (
            <div key={activity.id} className="flex items-start gap-3">
              <Avatar className="h-8 w-8">
                <AvatarImage src={activity.user.image ?? undefined} />
                <AvatarFallback>
                  {activity.user.name?.[0] || "U"}
                </AvatarFallback>
              </Avatar>
              <div className="flex-1 space-y-1">
                <p className="text-sm">
                  <span className="font-medium">{activity.user.name}</span>
                  {" "}{activity.description}
                </p>
                <p className="text-xs text-muted-foreground">
                  {activity.project && `${activity.project} · `}
                  {activity.time.toLocaleDateString("pt-BR", {
                    month: "short",
                    day: "numeric",
                    hour: "2-digit",
                    minute: "2-digit",
                  })}
                </p>
              </div>
            </div>
          ))}
          {activities.length === 0 && (
            <p className="text-sm text-muted-foreground">
              Nenhuma atividade recente
            </p>
          )}
        </div>
      </CardContent>
    </Card>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza uma lista mapeando recentTasks, exibindo cada t.
Conteúdo visível: Feed de Atividades

(2) Comparação de Estratégias de Aquisição de Dados

Estratégia Tempo Total Complexidade de Implementação Cenários Adequados
await serial Soma(N) Simples Dependências de dados
Promise.all Max(N) Média Fontes de dados independentes
Promise.allSettled Max(N) Alta Falhas parciais são toleradas
Streaming + PPR TTFB ~0 Alta Prioridade da Primeira Tela


6. Error Boundaries e Loading Skeletons

(1) Nível de Error Boundary

100%
graph TB
    A[RootLayout] --> B[error.tsx<br/>Erro Global]
    A --> C[DashboardLayout]
    C --> D[error.tsx<br/>Erro do Dashboard]
    D --> E[Dashboard Page]
    E --> F[Widget 1<br/>Suspense fallback]
    E --> G[Widget 2<br/>Suspense fallback]
    E --> H[Widget 3<br/>Error Boundary]

    style B fill:#f8d7da
    style D fill:#f8d7da
    style F fill:#d4edda
    style G fill:#d4edda

▶ Exemplo: Error Boundary Global

Saída:

TEXT 📖 Somente leitura
Diagrama da hierarquia de error boundaries: raiz → layout → tratamento de erro em nível de página.
TSX
// src/app/(dashboard)/error.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex min-h-[400px] items-center justify-center p-6">
      <Card className="mx-auto max-w-md">
        <CardHeader>
          <CardTitle className="text-destructive">Algo deu errado</CardTitle>
          <CardDescription>
            Ocorreu um erro inesperado ao carregar o dashboard.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
            {error.message || "Erro desconhecido"}
          </div>
          {error.digest && (
            <p className="mt-2 text-xs text-muted-foreground">
              ID do Erro: {error.digest}
            </p>
          )}
        </CardContent>
        <CardFooter>
          <Button onClick={() => reset()} variant="outline" className="w-full">
            Tentar novamente
          </Button>
        </CardFooter>
      </Card>
    </div>
  )
}

Saída:

TEXT 📖 Somente leitura
Renderiza: Algo deu errado | Ocorreu um erro inesperado ao carregar o dashboard.


7. Página Estática de Detalhes do Projeto

(1) Estratégia de Geração de Páginas Estáticas

Estratégia Momento da Geração Atualização dos Dados Tempo de Build Cenários Aplicáveis
SSG (generateStaticParams) No build Snapshot do build Lento (N páginas) Conteúdo raramente muda
ISR (revalidate) No build + sob demanda Atraso TTL Lento (N páginas) Atualizações periódicas
SSR (dinâmico) Na requisição Tempo real Rápido (sem N necessário) Dados em tempo real
PPR No build + Streaming Parte dinâmica em tempo real Rápido (shell estático) Cenários mistos


8. Grupos de Rotas e Organização de Layout

(1) Estrutura de Grupos de Rotas

TEXT 📖 Somente leitura
src/app/
├── (auth)/                 # Grupo de Rotas de Autenticação (Sem sidebar)
│   └── auth/
│       ├── signin/
│       └── signup/
├── (dashboard)/            # Grupo de Rotas do Dashboard (Com sidebar)
│   ├── layout.tsx          # Layout do Dashboard (Sidebar + TopNav)
│   ├── dashboard/
│   │   └── page.tsx
│   └── projects/
│       └── [id]/
│           └── page.tsx
├── layout.tsx              # Root Layout (html/body)
└── page.tsx                # Página Inicial (Pública)


9. Exemplo Completo: Página Completa do Dashboard

TSX
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { TaskCompletionWidget } from "@/components/dashboard/task-completion-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { RecentProjectsWidget } from "@/components/dashboard/recent-projects-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"

export default async function DashboardPage() {
  const session = await auth()

  if (!session?.user) {
    redirect("/auth/signin")
  }

  return (
    <div className="space-y-6">
      <Suspense fallback={<div className="h-24 animate-pulse rounded-lg bg-muted" />}>
        <WelcomeBanner user={session.user} />
      </Suspense>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<StatCardSkeleton />}>
          <ProjectCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <MemberCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCompletionWidget />
        </Suspense>
      </div>

      <div className="grid gap-6 lg:grid-cols-7">
        <div className="lg:col-span-4">
          <Suspense
            fallback={
              <div className="h-96 animate-pulse rounded-lg bg-muted" />
            }
          >
            <ActivityFeedWidget />
          </Suspense>
        </div>
        <div className="lg:col-span-3">
          <Suspense
            fallback={
              <div className="h-96 animate-pulse rounded-lg bg-muted" />
            }
          >
            <RecentProjectsWidget />
          </Suspense>
        </div>
      </div>
    </div>
  )
}

Saída esperada (ao acessar /dashboard no navegador):

TEXT 📖 Somente leitura
→ Visto em um instante: Sidebar + Navegação Superior + Breadcrumbs (Shell estático)
→ 0,3s: Banner de boas-vindas aparece
→ 0,5s: 4 Modelos de Cartão Estatístico → Preenchem os dados (12 / 50 / 5 / 68%)
→ 1,2s: Fluxo de Atividades + Lista de Projetos Recentes
→ Página Totalmente Interativa

❓ Perguntas Frequentes

P: Qual é a diferença entre PPR e loading.tsx? R: loading.tsx trata o estado de carregamento da página inteira (substituição total da página), enquanto o <Suspense> do PPR é um carregamento com streaming em nível de componente. O PPR pode pré-gerar HTML estático durante o processo de build, com o conteúdo dinâmico sendo preenchido via streaming; o loading.tsx, por outro lado, é renderizado inteiramente de forma dinâmica.

P: O generateStaticParams é adequado para todas as páginas de projeto? R: Não, não é. Se houver um grande número de projetos (mais de 10.000), o tempo de build será muito longo. O TaskFlow realiza geração estática apenas para projetos ativos (ACTIVE) e combina isso com atualizações sob demanda via ISR. Para projetos em escala extremamente grande, recomendamos usar SSR combinado com uma estratégia de cache.

P: O que acontece se uma requisição falhar no Promise.all? R: Promise.all segue a estratégia "uma falha, todas falham". Para dados não críticos (como fluxos de atividades), usar Promise.allSettled é mais seguro — mesmo que o fluxo de atividades falhe, as estatísticas ainda serão exibidas. Um error boundary combinado com um fallback fornece uma experiência degradada.

P: O componente Breadcrumb que usa usePathname é um componente client-side? R: Sim, usePathname é um hook client-side. O componente Breadcrumb é muito pequeno (não busca dados), então não há problemas de desempenho com renderização client-side. Se o desempenho for uma preocupação, você pode implementá-lo usando headers().get("next-url") no servidor, mas o código será mais complexo.

P: Por que a sessão é verificada novamente no Dashboard Layout? R: Embora o middleware.ts já proteja a rota /dashboard, verificar novamente no Layout serve como uma "defesa em camadas" — para evitar que a verificação seja contornada ao acessar diretamente rotas aninhadas (como /dashboard/projects/xxx). O Next.js recomenda realizar verificações de autenticação tanto no layout quanto nas camadas de recuperação de dados.

P: As animações de tela skeleton afetam o CLS (Cumulative Layout Shift)? R: Não. A tela skeleton ocupa as mesmas dimensões (altura/largura) que o conteúdo real antecipadamente, então não causa mudanças de layout. Esta é uma vantagem-chave do PPR e do loading.tsx — permite que os usuários vejam a estrutura do layout imediatamente, eliminando o CLS causado por transições de tela branca.

P: Qual é a ordem de renderização para múltiplos limites Suspense? R: O Next.js os renderiza na ordem em que aparecem no JSX. No entanto, a busca de dados é feita em paralelo (usando Promise.all), então cada Suspense é renderizado assim que seus dados estão prontos, sem esperar por outros limites. Os usuários veem a página carregar progressivamente na ordem em que os dados ficam disponíveis, e não na ordem do DOM.


📖 Resumo

📝 Exercícios

  1. Exercício Básico (⭐): Implemente a página Dashboard no projeto TaskFlow, garantindo que os quatro cartões de estatísticas sejam renderizados usando shells estáticos PPR. Execute npm run build para verificar se o PPR está funcionando (verifique o log de build para as tags ○ (Static) e λ (Dynamic)).

  2. Exercício Avançado (⭐⭐): Substitua Promise.all por Promise.allSettled no widget de Fluxo de Atividades e adicione uma mensagem de erro (exiba "Não foi possível carregar a atividade" quando o fluxo de atividades falhar ao carregar, em vez de fazer o widget inteiro travar). Escreva o código para a UI de fallback.

  3. Desafio (⭐⭐⭐): Implemente uma página de detalhes do projeto totalmente funcional para o TaskFlow — suportando SSR (garantindo dados em tempo real) + generateStaticParams para ISR (revalidate: 60) nos 10 principais projetos ativos, com SSR para todos os outros projetos. Adicione uma tela skeleton loading.tsx + bordas error.tsx + breadcrumbs atualizados dinamicamente. Use o Lighthouse para comparar as diferenças de desempenho entre as três estratégias.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%