10 — Rotas e proteção
10 — Rotas e proteção
Três arquivos de dados (
paths.ts,routes.ts,menuTree.ts) e um de comportamento (AppRouter.tsx). Toda rota é declarada em tabela, nunca em JSX espalhado. Todo componente de página é carregado comlazy().
Separação dados / comportamento
| Pasta | Contém | Pode ter JSX? |
|---|---|---|
src/routes/ |
Dados (constantes) | Não |
src/router/ |
Montagem do router | Sim |
Isso permite consumir a tabela de rotas para outros fins (menu, breadcrumb, verificação de permissão) sem arrastar o React Router junto.
paths.ts
// src/routes/paths.ts
export const PATHS = {
LOGIN: "login",
DASHBOARD: "dashboard",
CADASTRO: {
FORNECEDOR: "cadastro/fornecedor",
TIPO_FORNECEDOR: {
INCLUIR: "cadastro/tipo-fornecedor/incluir",
ALTERAR: "cadastro/tipo-fornecedor/alterar",
EXCLUIR: "cadastro/tipo-fornecedor/excluir"
}
},
RELATORIOS: {
MENSAL: "relatorios/mensal",
ANUAL: "relatorios/anual"
}
} as const;
Regras:
-
Sem barra inicial.
AppRouterconcatena (path={`/${path}`}). Uma barra a mais gera//rota. -
as constno final — dá tipos literais e impede mutação acidental. - Chaves em
SCREAMING_SNAKE_CASE; valores emkebab-case. - Aninhamento espelha a hierarquia de URL, e normalmente a do menu.
- Português nos segmentos de URL (é sistema interno em pt-BR). Escolha um idioma e mantenha — ver os anti-padrões em 13.
routes.ts
// src/routes/routes.ts
import { lazy } from "react";
import { PATHS } from "./paths";
import { RelatoriosLayout } from "@pages/Relatorios";
export type RouteGuard = "public" | "protected" | "auth-only";
export interface AppRoute {
path: string;
element: React.LazyExoticComponent<React.ComponentType>;
guard: RouteGuard;
/** Layout intermediário opcional entre o shell e a página */
layout?: React.ComponentType;
}
export const routes: AppRoute[] = [
{
path: PATHS.LOGIN,
element: lazy(() => import("@pages/Login")),
guard: "auth-only"
},
{
path: PATHS.DASHBOARD,
element: lazy(() => import("@pages/Dashboard")),
guard: "protected"
},
{
path: PATHS.CADASTRO.FORNECEDOR,
element: lazy(() => import("@pages/Cadastro/Fornecedor")),
guard: "protected"
},
{
path: PATHS.RELATORIOS.MENSAL,
element: lazy(() => import("@pages/Relatorios/Mensal")),
guard: "protected",
layout: RelatoriosLayout
}
];
export const NotFoundPage = lazy(() => import("@pages/NotFound"));
Guards
| Guard | Comportamento | Uso |
|---|---|---|
auth-only |
Só para não autenticados. Autenticado é redirecionado ao dashboard | Login |
public |
Acessível sempre, sem shell | Termos, ajuda |
protected |
Exige sessão; renderiza dentro do shell (Main) |
Todo o resto |
Não existe default: guard é obrigatório. Isso é proposital — esquecer torna a rota pública por
acidente, e o compilador impede.
Lazy sempre
element: lazy(() => import("@pages/Cadastro/Fornecedor"));
Toda página é lazy. Sem exceção — até a de login. Cada tela vira um chunk próprio; o bundle inicial
não cresce com o sistema. Por isso pages/<Tela>/index.ts com export default é obrigatório.
Layouts intermediários
layout insere um componente entre o shell e a página, para grupos de telas que compartilham
sub-navegação (abas internas, cabeçalho de seção). O AppRouter agrupa rotas por layout
automaticamente. Omita quando não houver.
AppRouter.tsx
// src/router/AppRouter.tsx
import React, { Suspense } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "@context/AuthProvider";
import ProtectedRoute from "@components/ProtectedRoute";
import LoadingScreen from "@components/LoadingScreen";
import Main from "@components/layout/Main";
import { routes, NotFoundPage, type AppRoute } from "@/routes";
// agrupa por layout pra montar uma <Route> pai por layout
function groupByLayout(routeList: AppRoute[]) {
return routeList.reduce<Map<React.ComponentType | null, AppRoute[]>>((map, route) => {
const key = route.layout ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(route);
return map;
}, new Map());
}
const AppRouter: React.FC = () => {
const { isAuth } = useAuth();
const authOnlyRoutes = routes.filter((r) => r.guard === "auth-only");
const publicRoutes = routes.filter((r) => r.guard === "public");
const protectedRoutes = routes.filter((r) => r.guard === "protected");
const protectedByLayout = groupByLayout(protectedRoutes);
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<Navigate to={isAuth ? "/dashboard" : "/login"} replace />} />
{authOnlyRoutes.map(({ path, element: Element }) => (
<Route key={path} path={`/${path}`} element={isAuth ? <Navigate to="/dashboard" replace /> : <Element />} />
))}
{publicRoutes.map(({ path, element: Element }) => (
<Route key={path} path={`/${path}`} element={<Element />} />
))}
<Route
element={
<ProtectedRoute>
<Main />
</ProtectedRoute>
}>
{[...protectedByLayout.entries()].map(([Layout, layoutRoutes]) => {
const children = layoutRoutes.map(({ path, element: Element }) => (
<Route key={path} path={`/${path}`} element={<Element />} />
));
if (!Layout) return <React.Fragment key="__root">{children}</React.Fragment>;
return (
<Route key={Layout.displayName ?? Layout.name} element={<Layout />}>
{children}
</Route>
);
})}
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
);
};
export default AppRouter;
Pontos que não são acidentais:
-
Um
<Suspense>na raiz cobre todos oslazy(). Não envolva página por página. -
Rotas protegidas ficam sob uma única
<Route>pai comProtectedRoute+Main— o shell não remonta ao navegar entre telas protegidas. -
path="*"por último captura o 404. -
/redireciona conformeisAuth.
ProtectedRoute
// src/components/ProtectedRoute/ProtectedRoute.tsx
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@context/AuthProvider";
import LoadingScreen from "@components/LoadingScreen";
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuth, isLoading } = useAuth();
const location = useLocation();
// enquanto reconecta por token, nao decidir ainda
if (isLoading) return <LoadingScreen />;
if (!isAuth) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
};
export default ProtectedRoute;
O isLoading é essencial. Sem ele, um refresh de página redireciona para o login antes da
reconexão por token terminar. Foi por isso que App.tsx também exibe LoadingScreen enquanto
isLoading — ver 05.
state={{ from: location }} preserva o destino para redirecionar depois do login.
Adicionar uma rota
// 1. src/routes/paths.ts
CADASTRO: {
FORNECEDOR: "cadastro/fornecedor",
CLIENTE: "cadastro/cliente" // novo
}
// 2. src/routes/routes.ts
{
path: PATHS.CADASTRO.CLIENTE,
element: lazy(() => import("@pages/Cadastro/Cliente")),
guard: "protected"
}
// 3. src/routes/menuTree.ts
{ label: "Cliente", path: PATHS.CADASTRO.CLIENTE }
E crie src/pages/Cadastro/Cliente/index.ts com export default, senão o lazy() falha em runtime
sem erro de compilação.
Rotas com parâmetro
O padrão declarativo suporta parâmetro na string:
{
path: "cadastro/fornecedor/:oid",
element: lazy(() => import("@pages/Cadastro/Fornecedor/Detalhe")),
guard: "protected"
}
Na página, useParams(). Não coloque rota parametrizada no menuTree — não há valor de parâmetro
para navegar a partir do menu.
Com navegação por abas, rotas parametrizadas costumam ser dispensáveis: o contexto passa pelo
TabsContextem vez de pela URL. Se o projeto novo dispensar abas, rotas parametrizadas voltam a ser o caminho natural.