Neste mini-projeto guiado, você vai integrar todas as peças já construídas (rotas, guards, contexto de autenticação, interceptadores, refresh token, páginas de erro) em um fluxo completo e coerente: autenticação funcional, sessão persistente e navegação segura do início ao fim. O foco aqui não é reexplicar os conceitos isolados, mas sim montar o “encadeamento” correto entre UI, roteamento e camada de API para que a experiência do usuário seja consistente e o app seja resiliente a recarregamentos, expiração de token e estados intermediários.
Objetivo do mini-projeto
Você vai implementar (ou finalizar) um conjunto de funcionalidades que, juntas, caracterizam uma autenticação completa em SPA:
- Login com validação de formulário, estado de carregamento e tratamento de erro.
- Persistência de sessão (recarregar a página não derruba o usuário).
- Bootstrap da sessão ao abrir o app (restaurar tokens, buscar “me”, resolver expiração/refresh).
- Navegação segura: páginas privadas só renderizam após a sessão estar resolvida.
- Proteção de rotas e componentes com base em autenticação e permissões.
- Logout consistente (limpa estado, cancela dados sensíveis, redireciona).
- Tratamento de “token expirou no meio do uso”: refresh transparente e repetição da requisição.
- Experiência de retorno: manter a rota pretendida quando o usuário é redirecionado ao login.
Visão geral do fluxo (do ponto de vista do usuário)
1) Abrir o app
Ao abrir a SPA, o app entra em um estado de “verificando sessão”. Nesse momento, você ainda não sabe se o usuário está autenticado. O app tenta restaurar a sessão persistida e validar/atualizar o acesso. Só depois disso as rotas privadas podem ser exibidas.
2) Usuário não autenticado tenta acessar rota privada
O guard redireciona para /login, preservando a rota original (por exemplo, /app/profile) como returnUrl. Após login, o usuário volta exatamente para onde queria ir.
3) Usuário autenticado navega e faz requisições
As requisições saem com o token anexado. Se o token expirar, o refresh acontece e a requisição é repetida sem o usuário perceber (desde que o refresh seja válido). Se o refresh falhar, o usuário é deslogado e volta ao login.
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
Passo a passo prático: implementando o encadeamento completo
Passo 1: Definir contratos mínimos da API (mock ou backend real)
Mesmo que você já tenha um backend, vale explicitar os contratos esperados para alinhar a implementação do front-end. Um conjunto típico inclui endpoints de login, refresh, logout e me.

POST /auth/login→ retornaaccessToken,refreshToken(ou refresh via cookie), e opcionalmente dados básicos do usuário.POST /auth/refresh→ retorna novoaccessToken(e às vezes novo refresh).POST /auth/logout→ invalida refresh no servidor (quando aplicável).GET /me→ retorna o usuário atual (id, nome, email, roles/permissões).
O mini-projeto fica mais robusto quando o front-end não “confia” apenas no token local e sempre confirma o usuário via /me durante o bootstrap.
Passo 2: Criar (ou revisar) um AuthService com operações atômicas
Centralize as operações de autenticação em um serviço. A regra aqui é: componentes e páginas não devem conhecer detalhes de armazenamento, refresh, nem de parsing de token. Eles chamam métodos simples.
// services/authService.ts
import { api } from "../services/apiClient";
import { tokenStore } from "../services/tokenStore";
export type LoginPayload = { email: string; password: string };
export const authService = {
async login(payload: LoginPayload) {
const { data } = await api.post("/auth/login", payload);
tokenStore.set(data.accessToken, data.refreshToken);
return data;
},
async logout() {
try {
await api.post("/auth/logout");
} finally {
tokenStore.clear();
}
},
async refresh() {
const refreshToken = tokenStore.getRefreshToken();
if (!refreshToken) throw new Error("No refresh token");
const { data } = await api.post("/auth/refresh", { refreshToken });
tokenStore.setAccessToken(data.accessToken);
if (data.refreshToken) tokenStore.setRefreshToken(data.refreshToken);
return data;
},
async getMe() {
const { data } = await api.get("/me");
return data;
}
};Se o seu refresh for via cookie httpOnly, o método refresh() não precisa ler refreshToken do storage e pode apenas chamar /auth/refresh com withCredentials (no axios) ou credentials: "include" (no fetch). O importante é manter a assinatura do serviço estável.
Passo 3: Implementar um tokenStore simples e previsível
O tokenStore é um detalhe de infraestrutura. Ele deve oferecer operações pequenas: set/get/clear. Evite espalhar localStorage.getItem pelo app.
// services/tokenStore.ts
const ACCESS_KEY = "app.access";
const REFRESH_KEY = "app.refresh";
export const tokenStore = {
set(accessToken: string, refreshToken?: string) {
localStorage.setItem(ACCESS_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
},
setAccessToken(accessToken: string) {
localStorage.setItem(ACCESS_KEY, accessToken);
},
setRefreshToken(refreshToken: string) {
localStorage.setItem(REFRESH_KEY, refreshToken);
},
getAccessToken() {
return localStorage.getItem(ACCESS_KEY);
},
getRefreshToken() {
return localStorage.getItem(REFRESH_KEY);
},
clear() {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
}
};Mesmo que você use outra estratégia de armazenamento, a ideia do mini-projeto é: o restante do app não muda.
Passo 4: Bootstrap da sessão (restauração + validação) no AuthProvider
O ponto mais comum de bugs em SPAs é renderizar rotas privadas antes de saber se o usuário está autenticado. Para evitar isso, o AuthProvider precisa ter um estado explícito de inicialização, por exemplo: status: "checking" | "authenticated" | "unauthenticated".
// auth/AuthProvider.tsx
import React, { createContext, useEffect, useMemo, useState } from "react";
import { authService } from "../services/authService";
import { tokenStore } from "../services/tokenStore";
type AuthStatus = "checking" | "authenticated" | "unauthenticated";
type AuthState = {
status: AuthStatus;
user: any | null;
};
type AuthContextValue = {
status: AuthStatus;
user: any | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
};
export const AuthContext = createContext<AuthContextValue>({} as any);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ status: "checking", user: null });
async function bootstrap() {
const access = tokenStore.getAccessToken();
if (!access) {
setState({ status: "unauthenticated", user: null });
return;
}
try {
// Confirma usuário no backend. Se access expirou, o interceptor/refresh deve resolver.
const me = await authService.getMe();
setState({ status: "authenticated", user: me });
} catch (e) {
// Se falhar, tente refresh uma vez e repita /me
try {
await authService.refresh();
const me = await authService.getMe();
setState({ status: "authenticated", user: me });
} catch {
tokenStore.clear();
setState({ status: "unauthenticated", user: null });
}
}
}
useEffect(() => {
bootstrap();
}, []);
async function login(email: string, password: string) {
await authService.login({ email, password });
const me = await authService.getMe();
setState({ status: "authenticated", user: me });
}
async function logout() {
await authService.logout();
setState({ status: "unauthenticated", user: null });
}
async function refreshSession() {
await authService.refresh();
const me = await authService.getMe();
setState({ status: "authenticated", user: me });
}
const value = useMemo(
() => ({ status: state.status, user: state.user, login, logout, refreshSession }),
[state.status, state.user]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}Note que o bootstrap faz duas tentativas: primeiro /me, depois refresh + /me. Isso reduz “flash” de logout quando o access expira entre sessões.
Passo 5: Gate de inicialização para evitar renderização prematura
Em vez de deixar cada rota lidar com status === "checking", crie um componente de gate no topo da árvore (por exemplo, envolvendo o Router) ou no layout autenticado. A regra é: enquanto estiver “checking”, renderize um fallback neutro.
// auth/AuthGate.tsx
import React from "react";
import { useAuth } from "../auth/useAuth";
export function AuthGate({ children }: { children: React.ReactNode }) {
const { status } = useAuth();
if (status === "checking") {
return <div style={{ padding: 24 }}>Carregando sessão...</div>;
}
return <>{children}</>;
}Isso evita o problema clássico: a rota privada renderiza, dispara requests, e logo em seguida o app descobre que não havia sessão válida.
Passo 6: Página de Login com returnUrl e UX de erro
A página de login deve: (1) capturar returnUrl, (2) chamar login, (3) redirecionar para o destino, (4) exibir erro amigável e (5) bloquear múltiplos envios.
// pages/LoginPage.tsx
import React, { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/useAuth";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const params = new URLSearchParams(location.search);
const returnUrl = params.get("returnUrl") || "/app";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(email, password);
navigate(returnUrl, { replace: true });
} catch (err: any) {
setError("Credenciais inválidas ou erro de rede.");
} finally {
setLoading(false);
}
}
return (
<div style={{ padding: 24, maxWidth: 420 }}>
<h2>Login</h2>
<form onSubmit={onSubmit}>
<div>
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label>Senha</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<button disabled={loading}>{loading ? "Entrando..." : "Entrar"}</button>
</form>
</div>
);
}O replace: true evita que o usuário volte para a tela de login ao apertar “voltar” após autenticar.
Passo 7: Interceptor de API para anexar token e lidar com 401
O mini-projeto precisa garantir que qualquer request autenticado:
- Envie o access token automaticamente.
- Ao receber 401 por expiração, tente refresh e repita a requisição.
- Se refresh falhar, limpe sessão e redirecione (ou sinalize) logout.
A implementação exata depende do seu cliente HTTP. Abaixo um exemplo com axios, focando no encadeamento do mini-projeto (sem reexplicar a teoria):
// services/apiClient.ts
import axios from "axios";
import { tokenStore } from "./tokenStore";
import { authService } from "./authService";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL
});
api.interceptors.request.use((config) => {
const token = tokenStore.getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
let refreshing: Promise<any> | null = null;
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
if (!refreshing) {
refreshing = authService.refresh().finally(() => {
refreshing = null;
});
}
await refreshing;
const token = tokenStore.getAccessToken();
original.headers.Authorization = `Bearer ${token}`;
return api(original);
} catch (e) {
tokenStore.clear();
return Promise.reject(e);
}
}
return Promise.reject(error);
}
);O detalhe importante é usar uma variável de fila para evitar múltiplos refresh simultâneos quando várias requisições falham ao mesmo tempo.

Passo 8: Navegação segura com dados do usuário (Dashboard e Perfil)
Agora conecte páginas privadas reais ao estado de autenticação. O objetivo é: páginas privadas devem consumir user do contexto e, quando precisarem de dados adicionais, chamar a API já com token anexado.
// pages/ProfilePage.tsx
import React, { useEffect, useState } from "react";
import { useAuth } from "../auth/useAuth";
import { api } from "../services/apiClient";
export function ProfilePage() {
const { user } = useAuth();
const [details, setDetails] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
api.get("/profile")
.then((res) => {
if (alive) setDetails(res.data);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
if (loading) return <div style={{ padding: 24 }}>Carregando perfil...</div>;
return (
<div style={{ padding: 24 }}>
<h2>Perfil</h2>
<p>Usuário autenticado: {user?.email}</p>
<pre><code>{JSON.stringify(details, null, 2)}</code></pre>
</div>
);
}Repare que o componente não precisa saber nada sobre refresh. Se o access expirou, o interceptor faz o trabalho e o componente continua simples.
Passo 9: Logout seguro e limpeza de dados sensíveis
Além de limpar tokens e estado do AuthProvider, o logout deve considerar dados em cache (por exemplo, queries, stores e estados locais). Mesmo sem uma biblioteca de cache, você pode pelo menos garantir que páginas privadas não permaneçam visíveis após logout.
// components/TopBar.tsx
import React from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/useAuth";
export function TopBar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
async function onLogout() {
await logout();
navigate("/login", { replace: true });
}
return (
<div style={{ display: "flex", gap: 12, padding: 12, borderBottom: "1px solid #eee" }}>
<span>Logado como: {user?.email}</span>
<button onClick={onLogout}>Sair</button>
</div>
);
}Se você usa React Query, Redux ou outra camada de cache, este é o ponto para invalidar/limpar caches no logout (por exemplo, queryClient.clear()), evitando que dados do usuário anterior apareçam para o próximo login no mesmo dispositivo.
Passo 10: Amarrar tudo no App (ordem correta dos providers)
Um erro comum é colocar o Router fora do AuthProvider e perder acesso ao estado de autenticação em guards, ou não usar o AuthGate e renderizar cedo demais. Uma composição típica:
// App.tsx
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthProvider";
import { AuthGate } from "./auth/AuthGate";
import { AppRoutes } from "./routes/AppRoutes";
export function App() {
return (
<BrowserRouter>
<AuthProvider>
<AuthGate>
<AppRoutes />
</AuthGate>
</AuthProvider>
</BrowserRouter>
);
}O AuthGate garante que as rotas só são avaliadas quando a sessão já foi resolvida (autenticada ou não).
Checklist de validação do mini-projeto (testes manuais guiados)
Cenário A: Primeiro acesso sem sessão
- Acesse diretamente uma rota privada (ex.:
/app/profile). - Verifique se você é redirecionado para
/login?returnUrl=/app/profile. - Após login, confirme que voltou para
/app/profile.
Cenário B: Recarregar a página autenticado
- Faça login e navegue para uma rota privada.
- Dê refresh no navegador.
- Confirme que o app mostra “Carregando sessão...” brevemente e retorna autenticado, sem piscar conteúdo público.
Cenário C: Expiração do access token durante uso
- Com o app aberto, force expiração do access (por tempo curto no backend ou alterando o token).
- Dispare uma requisição (abrir Perfil, atualizar dados, etc.).
- Confirme que ocorre refresh e a requisição original é repetida com sucesso.
Cenário D: Refresh inválido
- Invalide o refresh token no backend (ou remova do storage).
- Dispare uma requisição que gere 401.
- Confirme que o app limpa tokens e volta ao login.
Cenário E: Logout
- Estando em uma rota privada, clique em “Sair”.
- Confirme redirecionamento para login e impossibilidade de voltar para a página privada via botão “voltar”.
Ajustes finos comuns (para deixar o mini-projeto “pronto para produção”)
Evitar chamadas duplicadas ao /me
Se você chama /me no bootstrap e também em páginas privadas ao montar, pode haver duplicidade. Uma abordagem simples é: usar o user do contexto para UI e chamar endpoints específicos apenas quando necessário. Se precisar de dados completos de perfil, diferencie /me (mínimo) de /profile (detalhado).
Sincronizar múltiplas abas
Se o usuário faz logout em uma aba, a outra aba deve reagir. Uma solução prática é escutar o evento storage e, ao detectar remoção do token, atualizar o estado para unauthenticated. Isso evita inconsistências de UI em múltiplas abas.
// auth/useTokenSync.ts
import { useEffect } from "react";
import { tokenStore } from "../services/tokenStore";
export function useTokenSync(onLogout: () => void) {
useEffect(() => {
function handler(e: StorageEvent) {
if (e.key === "app.access" && !tokenStore.getAccessToken()) {
onLogout();
}
}
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, [onLogout]);
}Você pode chamar esse hook dentro do AuthProvider para manter o estado consistente.
Padronizar mensagens de erro
Em vez de cada página decidir como exibir erros, padronize: erros 401/403/500 podem virar mensagens específicas. No mini-projeto, um passo prático é mapear erros do axios e retornar um tipo de erro de domínio (ex.: InvalidCredentialsError, NetworkError), simplificando a UI.
Bloquear navegação para rotas públicas quando autenticado (opcional)
Se o usuário já está autenticado, acessar /login pode redirecionar automaticamente para /app. Isso reduz confusão e reforça o fluxo. Implemente isso na própria página de login verificando status === "authenticated" e navegando para o destino padrão.