Numa SPA, o usuário navega sem recarregar a página inteira. Isso torna a experiência rápida, mas também exige que você modele explicitamente o que acontece quando uma rota não existe (404), quando existe mas o usuário não tem permissão (403) e quando a aplicação ainda está carregando dados ou código (fallbacks de carregamento). Esses estados não são “detalhes de UI”: eles afetam segurança percebida, clareza de navegação, suporte e até métricas de conversão.

Entendendo 404, 403 e estados de fallback
404 (Not Found) em SPAs
O 404 é o estado em que a URL não corresponde a nenhuma rota conhecida pela aplicação. Em React Router, isso normalmente é resolvido com uma rota “catch-all” (curinga) que renderiza uma página de não encontrado. Em SPAs, há dois tipos de 404 para considerar:
404 de roteamento no cliente: a aplicação carregou, mas nenhuma rota bateu. Você controla totalmente a UI.
404 do servidor: o usuário acessa diretamente uma URL (ex.: /app/relatorios) e o servidor não está configurado para servir o index.html para rotas do cliente. Esse é um problema de deploy/configuração, mas a página 404 do cliente não será exibida porque o app nem carregou.
Neste capítulo, o foco é o 404 no cliente (React Router). Ainda assim, é importante lembrar que, em produção, você deve configurar o servidor para “fallback para index.html” em rotas da SPA, e deixar 404 real apenas para assets inexistentes.
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
403 (Forbidden) e a diferença para 401
O 403 representa “você está autenticado (ou identificado), mas não tem permissão para acessar este recurso”. Em SPAs, 403 costuma aparecer em dois cenários:
Bloqueio por regra de UI/rota: você sabe que o usuário não tem permissão (por exemplo, role insuficiente) e decide renderizar uma página 403.
Resposta do backend: a API retorna 403 ao tentar acessar um endpoint. A UI precisa reagir e mostrar uma tela adequada ou um aviso contextual.
Já o 401 (Unauthorized) normalmente significa “não autenticado” (sem sessão válida). Muitos projetos tratam 401 redirecionando para login, enquanto 403 mantém o usuário logado e exibe “Acesso negado”. Como capítulos anteriores já cobriram guards, roles e interceptação de erros, aqui vamos focar em como transformar essas condições em páginas e fluxos de navegação consistentes.
Fallbacks de carregamento
Fallback é o que a UI mostra enquanto algo ainda não está pronto: carregamento de código (lazy loading), carregamento de dados (fetch), ou resolução de estado (por exemplo, restaurar sessão). Em React Router moderno, você pode lidar com isso de várias formas:
React.Suspense para carregamento de módulos via
lazy().Data routers (loaders) com
defer/Await(quando aplicável) para carregamento de dados.Estados locais (skeleton/spinner) em páginas e componentes.
O objetivo é evitar telas em branco, evitar “piscadas” de layout e comunicar claramente o que está acontecendo.
Estratégia prática: padronizar páginas de erro e carregamento
Antes do passo a passo, defina um padrão simples:
NotFound (404): rota inexistente; oferecer ação de voltar para home/área inicial e, opcionalmente, um link para suporte.
Forbidden (403): rota existente, mas acesso negado; oferecer ação de voltar para uma área permitida e, opcionalmente, trocar de conta.
Loading fallback: componente reutilizável (ex.:
<PageLoader />) e, quando possível, skeletons específicos por página.Error boundary de rota: capturar erros inesperados de renderização/loader e mostrar uma página “Algo deu errado” sem derrubar a SPA inteira.
Mesmo que você não implemente todas as camadas de uma vez, ter nomes e responsabilidades claras evita duplicação e inconsistência.
Passo a passo: criando páginas 404 e 403
1) Criar componentes de página
Crie páginas simples e reutilizáveis. Evite colocar lógica de permissão dentro delas; elas devem apenas apresentar o estado e ações de navegação.
import { Link, useNavigate } from "react-router-dom";export function NotFoundPage() { const navigate = useNavigate(); return ( <div style={{ padding: 24 }}> <h2>Página não encontrada (404)</h2> <p>A rota acessada não existe ou foi movida.</p> <ul> <li><button onClick={() => navigate(-1)}>Voltar</button></li> <li><Link to="/">Ir para a página inicial</Link></li> </ul> </div> );}export function ForbiddenPage() { return ( <div style={{ padding: 24 }}> <h2>Acesso negado (403)</h2> <p>Você não tem permissão para acessar esta página.</p> <ul> <li><Link to="/">Ir para a página inicial</Link></li> <li><Link to="/app">Ir para a área autenticada</Link></li> </ul> </div> );}Observação: os links acima são exemplos. Ajuste para as rotas reais do seu app (por exemplo, uma dashboard padrão).
2) Registrar a rota 404 (catch-all)
No React Router, a rota curinga * deve ficar por último dentro do mesmo nível de rotas, para capturar qualquer caminho não mapeado.
import { Routes, Route } from "react-router-dom";import { NotFoundPage } from "./pages/NotFoundPage";export function AppRoutes() { return ( <Routes> {/* ...suas rotas... */} <Route path="*" element={<NotFoundPage />} /> </Routes> );}Se você tem rotas aninhadas (layouts), também pode querer um 404 “local” dentro de um segmento. Exemplo: rotas dentro de /app podem ter um 404 específico para a área autenticada, mantendo o layout (menu, topo, etc.).
<Route path="/app" element={<AppLayout />}> {/* ...rotas internas... */} <Route path="*" element={<NotFoundPage />} /></Route>Isso evita que um usuário caia num 404 “público” sem o contexto do app quando ele erra uma sub-rota dentro da área autenticada.
3) Registrar a rota 403 (página dedicada)
Para 403, é comum ter uma rota explícita (ex.: /403 ou /forbidden). Isso permite redirecionar para ela quando uma verificação de permissão falhar.
<Route path="/forbidden" element={<ForbiddenPage />} />Mesmo que você também possa renderizar <ForbiddenPage /> diretamente no lugar da página protegida, a rota dedicada facilita padronizar navegação, breadcrumbs e telemetria (ex.: medir quantas vezes usuários chegam ao 403).
Passo a passo: quando mostrar 403 (sem repetir guards)
Como a lógica de guards e roles já foi tratada anteriormente, aqui vamos focar em padrões de saída: o que fazer quando a permissão falha.
Padrão A: renderizar 403 no lugar do conteúdo
Esse padrão mantém a URL original. É útil quando você quer que o usuário entenda exatamente qual página tentou acessar, mas não pode.
import { ForbiddenPage } from "./pages/ForbiddenPage";export function ReportsPage() { const canView = false; // resultado de uma checagem já existente no seu app if (!canView) return <ForbiddenPage />; return ( <div> <h2>Relatórios</h2> <p>Conteúdo permitido...</p> </div> );}Cuidados: se você tem navegação lateral destacando a rota atual, ela continuará ativa. Isso pode ser desejável (o usuário “está” naquela área) ou confuso (parece que a página existe, mas está bloqueada). Avalie caso a caso.
Padrão B: redirecionar para uma rota /forbidden
Esse padrão troca a URL para uma página padrão de acesso negado. É útil quando você quer centralizar UI e reduzir variações.
import { Navigate, useLocation } from "react-router-dom";export function RequirePermission({ allowed, children }) { const location = useLocation(); if (!allowed) { return <Navigate to="/forbidden" replace state={{ from: location }} />; } return children;}O state.from permite exibir na página 403 de onde o usuário veio (ou oferecer um botão “voltar”), sem expor detalhes sensíveis. Na página 403, você pode ler esse estado e decidir a melhor ação.
Fallbacks de carregamento: evitando telas em branco
1) Fallback para lazy loading com Suspense
Quando você divide o bundle e carrega páginas sob demanda, o React precisa de um fallback enquanto o chunk é baixado. Um padrão é envolver o “miolo” das rotas com <Suspense>.
import { Suspense, lazy } from "react";import { Routes, Route } from "react-router-dom";const SettingsPage = lazy(() => import("./pages/SettingsPage"));function PageLoader() { return ( <div style={{ padding: 24 }}> <p>Carregando...</p> </div> );}export function AppRoutes() { return ( <Suspense fallback={<PageLoader />}> <Routes> <Route path="/app/settings" element={<SettingsPage />} /> <Route path="*" element={<NotFoundPage />} /> </Routes> </Suspense> );}Dica prática: se você tem layouts diferentes (público vs autenticado), pode colocar um Suspense por layout para que o fallback mantenha a moldura (header/sidebar) e só o conteúdo central mostre carregamento.
2) Fallback de carregamento de dados dentro da página
Mesmo sem data routers, você frequentemente terá páginas que buscam dados. O padrão é: estado loading, estado error, estado empty e estado success. O erro aqui não é 403/404 de rota, mas falhas de rede, 500, timeouts etc. Ainda assim, um fallback consistente melhora muito a UX.
import { useEffect, useState } from "react";function InlineError({ message }) { return <p style={{ color: "crimson" }}>{message}</p>;}export function ProfilePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [profile, setProfile] = useState(null); useEffect(() => { let alive = true; (async () => { try { setLoading(true); setError(null); const res = await fetch("/api/me"); if (!res.ok) { if (res.status === 403) throw new Error("forbidden"); throw new Error("request_failed"); } const data = await res.json(); if (alive) setProfile(data); } catch (e) { if (!alive) return; setError(e.message); } finally { if (alive) setLoading(false); } })(); return () => { alive = false; }; }, []); if (loading) return <p>Carregando perfil...</p>; if (error === "forbidden") return <ForbiddenPage />; if (error) return <InlineError message="Não foi possível carregar." />; if (!profile) return <p>Sem dados.</p>; return ( <div> <h2>Meu perfil</h2> <p>Nome: {profile.name}</p> </div> );}Note que, quando o backend devolve 403, você pode optar por renderizar a página 403 mesmo dentro de uma rota válida. Isso é útil quando a permissão depende de dados do servidor (por exemplo, feature flags, assinatura expirada, bloqueio administrativo).
3) Skeletons e estabilidade visual
Um loader genérico é melhor do que tela em branco, mas skeletons melhoram a percepção de velocidade.

A regra prática:
Use spinner/loader para esperas curtas e ações pontuais (ex.: salvar).
Use skeleton para páginas com layout previsível (ex.: cards, tabelas).
Mantenha o layout estável: reserve espaço para cabeçalhos, cards e tabelas para evitar “pulo” de conteúdo.
Mesmo sem biblioteca, você pode criar um skeleton simples com CSS e blocos.
export function TableSkeleton() { return ( <div style={{ padding: 24 }}> <div style={{ height: 24, width: 220, background: "#eee", marginBottom: 16 }} /> <div style={{ height: 14, width: "100%", background: "#eee", marginBottom: 8 }} /> <div style={{ height: 14, width: "100%", background: "#eee", marginBottom: 8 }} /> <div style={{ height: 14, width: "100%", background: "#eee", marginBottom: 8 }} /> </div> );}Erros de rota além de 404/403: errorElement e boundaries
Além de “rota não encontrada” e “acesso negado”, existem erros inesperados: exceções durante renderização, falhas em loaders (se você usa data routers), ou bugs de componentes. Em vez de quebrar a SPA inteira, você pode definir um errorElement por segmento de rota para capturar e exibir uma tela de erro contextual.
Exemplo conceitual com rotas que suportam errorElement:
import { createBrowserRouter } from "react-router-dom";function RouteErrorPage() { return ( <div style={{ padding: 24 }}> <h2>Ocorreu um erro</h2> <p>Tente recarregar a página ou voltar.</p> </div> );}export const router = createBrowserRouter([ { path: "/app", element: <AppLayout />, errorElement: <RouteErrorPage />, children: [ { path: "settings", element: <SettingsPage /> }, { path: "*", element: <NotFoundPage /> } ] }, { path: "/forbidden", element: <ForbiddenPage /> }, { path: "*", element: <NotFoundPage /> }]);Esse padrão é especialmente útil para não confundir erros de aplicação com 404. Se um componente quebra, não faz sentido mostrar “Página não encontrada”; faz mais sentido mostrar “Erro ao carregar esta tela”.
Integração com respostas do backend: 404 e 403 de recurso
Nem todo 404 vem do roteamento. Às vezes a rota existe, mas o recurso não: por exemplo, /app/users/123 existe, mas o usuário 123 não. Nesse caso, o backend pode retornar 404 e você deve mostrar um estado de “recurso não encontrado” dentro da página (ou uma página dedicada).
Um padrão simples é separar:
404 de rota: URL inválida na navegação do app →
NotFoundPagevia rota*.404 de recurso: entidade inexistente →
ResourceNotFounddentro da página, mantendo breadcrumbs e contexto.
function ResourceNotFound({ entityName }) { return ( <div style={{ padding: 24 }}> <h3>{entityName} não encontrado</h3> <p>Verifique se o link está correto ou se o item foi removido.</p> </div> );}Da mesma forma, um 403 pode ser “por rota” (regra conhecida no cliente) ou “por recurso” (o usuário não pode ver aquele item específico). A UI pode variar: em alguns casos, é melhor mostrar 404 para não revelar a existência do recurso (decisão de segurança do backend). No front-end, respeite o código e a mensagem que a API retorna, mas mantenha consistência visual.
Checklist de implementação (para evitar armadilhas comuns)
Catch-all no nível correto: tenha
*global e, se necessário,*dentro de segmentos como/app.403 não é 404: não use 404 para tudo; diferencie acesso negado de rota inexistente.
Fallback sempre visível: envolva rotas lazy com
Suspensee garanta que o fallback não “quebre” o layout.Evite loops de navegação: ao redirecionar para
/forbidden, usereplacepara não poluir histórico e evitar voltar para a mesma rota bloqueada repetidamente.Mensagens acionáveis: ofereça caminhos claros (voltar, ir para home, ir para dashboard) em 404 e 403.
Telemetria: registre eventos de 404/403 (por exemplo, em analytics/logs) para identificar links quebrados e problemas de permissão.