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
- Construir um layout de navegação global (Sidebar + TopNav + Breadcrumb)
- Modo Dashboard PPR Static Shell (
<Suspense>Widget Dinâmico) Promise.allRecuperação paralela de dados de múltiplas fontes independenteserror.tsxError Boundaries Globais +loading.tsxDesign de Tela SkeletongenerateStaticParamsPré-gerar páginas de detalhes de projetos estáticas- Consultas Agregadas em Dados de Fluxo de Atividades
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.allpara carregar 4 fontes de dados em paralelo.
// 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
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
// 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:
Inclui uma barra lateral.
// 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:
Renderiza: TF | TaskFlow | CÓDIGO DE CONVITE
▶ Exemplo: Barra de Navegação Superior
Saída:
O componente renderiza a UI descrita no navegador.
// 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:
Uma página com uma seção de cabeçalho.
Texto visível: \uD83D\uDD14 | 3 | \u2753
▶ Exemplo: Navegação Breadcrumb
Saída:
A página renderiza conforme descrito acima, com a UI atualizando com base no comportamento descrito.
// 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:
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
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
// 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:
Módulo TypeScript executado com sucesso.
// 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:
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
// 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>
)
}
// 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
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:
Diagrama de rotas paralelas: @sidebar e @modal renderizam simultaneamente em slots nomeados.
// 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:
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
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:
Diagrama da hierarquia de error boundaries: raiz → layout → tratamento de erro em nível de página.
// 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:
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
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
// 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):
→ 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.allsegue a estratégia "uma falha, todas falham". Para dados não críticos (como fluxos de atividades), usarPromise.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 usandoheaders().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
- Dashboard Layout: Use Grupos de Rotas
(dashboard)para isolar grupos de rotas que exigem uma sidebar - O shell estático PPR envolve o widget dinâmico usando
<Suspense>e gera um esqueleto HTML durante o build Promise.allAo recuperar dados de quatro fontes independentes em paralelo, o tempo total necessário foi reduzido de 8,2 segundos para 2,1 segundoserror.tsxfornece error boundaries globais e locais; a funçãoreset()suporta tentativasloading.tsx+ componente Skeleton screen elimina a tela em branco e melhora o LCP para 1,2 segundosgenerateStaticParamsPré-gera páginas de detalhes de projetos ativos e as atualiza sob demanda usando ISR- O fluxo de atividades usa
Promise.allpara realizar consultas paralelas em Task e Comment, depois mescla e ordena os resultados
📝 Exercícios
-
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 buildpara verificar se o PPR está funcionando (verifique o log de build para as tags○ (Static)eλ (Dynamic)). -
Exercício Avançado (⭐⭐): Substitua
Promise.allporPromise.allSettledno 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. -
Desafio (⭐⭐⭐): Implemente uma página de detalhes do projeto totalmente funcional para o TaskFlow — suportando SSR (garantindo dados em tempo real) +
generateStaticParamspara ISR (revalidate: 60) nos 10 principais projetos ativos, com SSR para todos os outros projetos. Adicione uma tela skeletonloading.tsx+ bordaserror.tsx+ breadcrumbs atualizados dinamicamente. Use o Lighthouse para comparar as diferenças de desempenho entre as três estratégias.