Aprenda / Frontend — Tailwind & Next.js

IntermediárioAula 20 de 2026 min0% da trilha

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

middleware.ts
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)

app/dashboard/pedidos/page.tsx
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.

app/dashboard/pedidos/filtro-pedidos.tsx
"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)

app/dashboard/pedidos/actions.ts
"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
}
app/dashboard/pedidos/upload-comprovante-form.tsx
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?

Quiz rápido

Nesta tela, por que a checagem de sessão fica no middleware.ts em vez de dentro do page.tsx da rota de pedidos?
Por que o filtro de pedidos precisa ser um Client Component, enquanto a tabela que lista os pedidos continua um Server Component?