Projeto guiado: dashboard com tabela, filtro e upload
Junta middleware, tabela paginada, filtro debounced e upload numa única tela: um dashboard de pedidos protegido.
Hora de juntar o intermediário inteiro numa tela: middleware.ts protegendo a rota, um Server Component buscando pedidos paginados e filtrados, um Client Component isolado só pro filtro com debounce, e um upload de comprovante via Server Action. O projeto: um dashboard de pedidos.
1. A rota protegida
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};2. A tabela de pedidos paginada (Server Component)
type Props = { searchParams: Promise<{ page?: string; q?: string }> };
async function getPedidos(page: number, termo: string) {
const res = await fetch(
`https://api.exemplo.com/pedidos?page=${page}&pageSize=20&q=${termo}`,
{ cache: "no-store" }
);
return res.json() as Promise<{ itens: Pedido[]; total: number }>;
}
export default async function PedidosPage({ searchParams }: Props) {
const { page = "1", q = "" } = await searchParams;
const { itens, total } = await getPedidos(Number(page), q);
return (
<div className="max-w-3xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Pedidos</h1>
<FiltroPedidos termoInicial={q} />
<table className="w-full mt-4 border-collapse">
<tbody>
{itens.map((pedido) => (
<tr key={pedido.id} className="border-b">
<td className="py-2">{pedido.cliente}</td>
<td className="py-2 text-right">R$ {pedido.total.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
<p className="text-sm text-slate-500 mt-2">
Página {page} — {total} pedidos no total
</p>
</div>
);
}3. O filtro com debounce (Client Component)
O filtro precisa de estado local e de um evento de digitação — por isso é o único pedaço marcado "use client". Ao mudar, ele atualiza a URL (?q=...), o que faz o Next.js re-executar o Server Component acima com o novo filtro.
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
function useDebouncedValue<T>(valor: T, delay: number) {
const [debounced, setDebounced] = useState(valor);
useEffect(() => {
const timeout = setTimeout(() => setDebounced(valor), delay);
return () => clearTimeout(timeout);
}, [valor, delay]);
return debounced;
}
export function FiltroPedidos({ termoInicial }: { termoInicial: string }) {
const [termo, setTermo] = useState(termoInicial);
const termoDebounced = useDebouncedValue(termo, 300);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (termoDebounced) params.set("q", termoDebounced);
else params.delete("q");
router.push(`${pathname}?${params.toString()}`);
}, [termoDebounced, pathname, router, searchParams]);
return (
<input
value={termo}
onChange={(e) => setTermo(e.target.value)}
placeholder="Buscar por cliente..."
className="border rounded-lg px-3 py-2 w-full"
/>
);
}4. Upload do comprovante (Server Action)
"use server";
import { revalidatePath } from "next/cache";
export async function enviarComprovante(formData: FormData) {
const arquivo = formData.get("arquivo") as File;
const pedidoId = formData.get("pedidoId") as string;
if (!arquivo || arquivo.size === 0) throw new Error("Selecione um arquivo");
await salvarComprovante(pedidoId, arquivo);
revalidatePath("/dashboard/pedidos"); // tabela reflete o novo status no próximo carregamento
}import { enviarComprovante } from "./actions";
export function UploadComprovanteForm({ pedidoId }: { pedidoId: string }) {
return (
<form action={enviarComprovante} className="flex items-center gap-2">
<input type="hidden" name="pedidoId" value={pedidoId} />
<input type="file" name="arquivo" accept="image/*,application/pdf" />
<button type="submit" className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm">
Enviar comprovante
</button>
</form>
);
}O ponto central da tela inteira: PedidosPage é async e busca dado já paginado e filtrado (lições 8 e 19); FiltroPedidos é "use client" só porque precisa de estado de digitação e debounce (lição 12); e UploadComprovanteForm nem precisa de "use client" — ele usa uma Server Action direto como action do form (lição 15), que chama revalidatePath pra garantir que a tabela mostre o pedido atualizado na próxima navegação. Tudo isso só existe porque o middleware.ts já garantiu, antes de qualquer coisa renderizar, que quem chegou até aqui está autenticado (lição 16).
Exercício
Depois que enviarComprovante roda com sucesso, o usuário ainda está vendo a tabela antiga na tela (o revalidatePath só afeta a próxima navegação/carregamento). O que você adicionaria no UploadComprovanteForm pra tabela atualizar imediatamente após o envio, sem o usuário precisar recarregar a página manualmente?