name: lovable-vps-blank-screen-debug
description: Debug "tela preta" (blank screen) on Lovable/TanStack Start apps deployed to VPS — client-side first, server-side second.
Lovable/TanStack Start VPS — Tela Preta (Blank Screen) Debug
Trigger
User reports "tela preta", "tela branca", or blank/empty page on a Lovable-deployed or TanStack Start app hosted on VPS.
Symptom
Page loads HTML (correct title, CSS, fonts), but body renders empty or shows streaming SSR markers () with no visible content.
Debug Sequence (in order — do NOT skip to server forensics first)
Step 1: Browser Console (F12) — ALWAYS start here
Ask the user to:
1. Open the page
2. Press F12 → Console tab
3. Report any red/yellow errors
This takes 30 seconds and rules out 90% of causes (extensions, cache, JS disabled).
Step 2: Hard refresh
Ctrl+Shift+R (or Cmd+Shift+R) to clear browser cache and reload assets.
Step 3: Incognito/Private window
Open in incognito mode — bypasses extensions and stale cache.
Step 4: Different browser
Chrome vs Firefox vs Edge — isolates browser-specific issues.
Step 5: Only after Steps 1-4 are clean, investigate server-side:
- - Server logs:
journalctl -uor-n 50 --no-pager tail -20 /var/log/.err.log - - curl HTML directly:
curl -s https://your-domain.com/ | python3 -c "import sys; html=sys.stdin.read(); print('Body empty:', '' in html)" - - Check if JS assets return 200:
curl -sI https://your-domain.com/assets/.js - - Check Supabase auth health:
curl -s https://.supabase.co/auth/v1/settings - - Browser extension (e.g., ad blocker, Cuponomia) injecting shadow DOM or blocking scripts
- - Stale cache — old JS bundle cached, new build has different hash
- - JavaScript disabled in browser
- - CORS blocking font/assets (check Network tab)
- - Supabase project ID mismatch or missing
SUPABASE_SERVICE_ROLE_KEY - -
VITE_env vars in SSR context: Supabase clients initialized withVITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEYonly exist in Vite (browser build). In Node.js SSR, these areundefined. If the Supabase client or any hook/call using it runs during SSR without guards, it can fail silently. Useprocess.env.SUPABASE_URL+process.env.SUPABASE_SERVICE_ROLE_KEYfor SSR, or guard all Supabase calls withtypeof window !== 'undefined'. - - NUNCA usar
window.location.reload()após salvar sessão — causa loop infinito comRequireAuth - - SEMPRE usar
window.history.replaceState({}, "", window.location.pathname)antes dewindow.location.href = "/"para limpar o?code=da URL - - NUNCA fazer
setTimeoutpara redirect — não é necessário com PKCE
Common Causes (client-side first)
Common Causes (server-side — after client rules out)
OAuth / Login não funciona — Lovable Broker OAuth não existe no VPS
Sintoma
Botão "Entrar com Google" dá 404 ou não faz nada. O app Lovable usa @lovable.dev/cloud-auth-js com broker OAuth (/~oauth/initiate) que é um serviço da plataforma Lovable Cloud — não existe no VPS, causando falha silenciada.
Solução
Substituir o auth da Lovable pelo Supabase OAuth direto. Fluxo correto:
`
Usuário clica "Entrar com Google"
→ supabase.auth.signInWithOAuth({ provider: 'google' })
→ Supabase redireciona para Google OAuth
→ Google redireciona de volta para
→ supabase.auth.exchangeCodeForSession(code)
→ Redireciona para /
`
Arquivos a alterar
1. auth.tsx (não usa mais @lovable.dev/cloud-auth-js):
`tsx
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { AppLogo } from "@/components/AppLogo";
import { toast } from "sonner";
export const Route = createFileRoute("/auth")({
ssr: false, // ← CRÍTICO: força CSR, hash fica acessível no useEffect
component: AuthPage,
});
function AuthPage() {
const [loading, setLoading] = useState(false);
const timeoutRef = useRef
useEffect(() => {
// OAuth callback — roda 100% no cliente (ssr: false garante isso)
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
if (code) {
// Fluxo PKCE: trocar código por sessão
console.log("[Auth] PKCE code received, exchanging...");
window.history.replaceState({}, "", window.location.pathname); // limpa ?code= da URL
supabase.auth.exchangeCodeForSession(code).then(({ data, error }) => {
if (error) {
console.error("[Auth] exchangeCodeForSession error:", error);
toast.error("Erro ao fazer login: " + error.message);
setLoading(false);
return;
}
console.log("[Auth] Session created, user:", data.session?.user?.email);
if (data.session) {
// ✅ NUNCA usar reload — causa loop infinito com RequireAuth
window.location.href = "/";
}
});
return;
}
// Sessão já existente?
supabase.auth.getSession().then(({ data }) => {
if (data.session) {
console.log("[Auth] Already logged in:", data.session.user?.email);
window.location.href = "/";
}
});
}, []);
async function signInGoogle() {
setLoading(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setLoading(false);
toast.error("Tempo esgotado. Tente novamente.");
}, 20000);
try {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: ${window.location.origin}/auth,
queryParams: {
prompt: "select_account",
access_type: "offline",
scope: "email profile",
},
},
});
if (error) {
console.error("[Auth] signInWithOAuth error:", error);
toast.error(error.message || "Falha no login");
await supabase.auth.signOut().catch(() => {});
return;
}
// URL retornada = browser está navegando para o Google
if (data?.url) {
console.log("[Auth] Redirecting to:", data.url);
return;
}
} catch (err: any) {
console.error("[Auth] Exception:", err);
toast.error(err?.message || "Erro ao autenticar");
await supabase.auth.signOut().catch(() => {});
} finally {
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
setLoading(false);
}
}
return (
DocCorretor
);
}
`
⚠️ REGRAS CRÍTICAS DO REDIRECT EM auth.tsx:
2. AuthProvider — usar refreshSession() no mount (em vez de getSession()):
`tsx
// No useEffect do AuthProvider:
supabase.auth.refreshSession().then(({ data }) => { // ← refreshSession, não getSession
if (!mounted) return;
setSession(data.session);
setUser(data.session?.user ?? null);
if (data.session?.user) loadProfile(data.session.user.id);
setLoading(false);
});
`
Isso força o SDK a usar o refresh_token se o access_token estiver expirado, evitando o caso em que getSession() retorna null mesmo com tokens válidos no localStorage.
Fluxo OAuth correto:
`
Usuário clica "Entrar com Google"
→ supabase.auth.signInWithOAuth({ provider: 'google', redirectTo: '/auth' })
→ Supabase redireciona para Google OAuth
→ Google redireciona de volta para
→ useEffect detecta ?code=, chama exchangeCodeForSession(code)
→ Redireciona para /
`
2. Configurar Supabase via API (ativar Google OAuth no projeto):
`bash
Encontrar a API key do service role do Supabase
Está no Dashboard Supabase → Project Settings → API
SUPABASE_SERVICE_KEY="sbp_REDACTED"
1. Ativar provider Google
curl -s -X PATCH "https://api.supabase.com/v1/projects/{ref}/config/auth" \
-H "Authorization: Bearer $SUPABASE_SERVICE_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_google_enabled": true,
"external_google_client_id": "GOOGLE_CLIENT_ID",
"external_google_secret": "GOOGLE_CLIENT_SECRET"
}'
2. Configurar site_url e redirect allow list
curl -s -X PATCH "https://api.supabase.com/v1/projects/{ref}/config/auth" \
-H "Authorization: Bearer $SUPABASE_SERVICE_KEY" \
-H "Content-Type: application/json" \
-d '{
"site_url": "https://seu-dominio.com",
"uri_allow_list": "https://seu-dominio.com,https://seu-dominio.com/auth"
}'
`
3. No Google Cloud Console: adicionar redirect URI:
`
https://{project-ref}.supabase.co/auth/v1/callback
`
Armadilha comum: projeto Supabase errado
Muitos projetos Lovable criam um projeto Supabase temporário durante o desenvolvimento. O banco de dados real pode estar em outro projeto Supabase (ex: CommercialRS usa dauftiqcvgaydddoxqhh). Verifique sempre qual projeto Supabase tem os dados antes de configurar OAuth.
Armadilha: site_url do Supabase pointing para localhost
Se site_url estiver como http://localhost:3000, o Supabase ignora o redirect. Configure sempre:
`bash
"site_url": "https://seu-dominio.com"
`
OAuth: Supabase retorna token no HASH (#access_token=) e não como ?code=
Sintoma: Após clicar "Entrar com Google", o browser vai para /auth#access_token=... mas o login nunca funciona. O getSession() sempre retorna hasSession: false. Aparece 401 em /auth/v1/user.
Diagnóstico: O Supabase pode usar o fluxo implícito (#access_token=...) em vez do fluxo PKCE (?code=). Isso acontece quando redirectTo não é reconhecido pelo Supabase como URL válida do projeto.
Sintomas no console:
`
[Auth] Token received in hash. accessToken present: true
[Auth] setSession result: { error: null }
[Auth] getSession after setSession: { hasSession: false }
GET /auth/v1/user 401
`
Causa-raiz: O token chega no hash mas o Supabase SDK com detectSessionInUrl: false não faz parse automático. Quando setSession() é chamado com tokens do hash, o token pode estar expirado (clock skew entre servidor e Supabase). Mesmo sem erro em setSession(), o token expirado causa 401 no próximo /auth/v1/user.
Solução completa em auth.tsx — detectar hash, extrair tokens, usar setSession() + refreshSession():
`tsx
useEffect(() => {
const hash = window.location.hash;
const search = window.location.search;
// SUPABASE PODE USAR FLOW IMPLÍCITO (#access_token=) OU PKCE (?code=)
const hasHashCallback = hash.includes("access_token=");
const hasCodeCallback = search.includes("code=");
if (hasHashCallback) {
// Fluxo implícito: token no hash fragment
const hashParams = new URLSearchParams(hash.substring(1));
const accessToken = hashParams.get("access_token");
const refreshToken = hashParams.get("refresh_token") || "";
if (accessToken) {
// 1. Salvar tokens manualmente (SDK não faz isso com detectSessionInUrl:false)
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
if (error) { toast.error("Erro: " + error.message); return; }
// 2. Forçar refresh — token pode estar expirado na chegada
const { data: refreshData, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError || !refreshData?.session) {
toast.error("Sessão não criada. Tente novamente.");
return;
}
// 3. Limpar hash da URL
window.history.replaceState({}, "", window.location.pathname);
window.location.href = "/";
}
return;
}
if (hasCodeCallback) {
// Fluxo PKCE: trocar código por sessão
const code = new URLSearchParams(search).get("code");
if (code) {
window.history.replaceState({}, "", window.location.pathname);
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
if (error || !data.session) {
toast.error("Erro no login. Tente novamente.");
return;
}
window.location.href = "/";
}
return;
}
// Nenhum callback — verificar sessão existente
const { data } = await supabase.auth.getSession();
if (data.session) window.location.href = "/";
}, []);
`
Verificação manual do token (no terminal, decodificar JWT):
`bash
Extrair o access_token do URL hash (entre #access_token= e &)
Assumindo token na variável $TOKEN:
echo "$TOKEN" | cut -d. -f1,2 | base64 -d 2>/dev/null | python3 -c "
import sys,json
t=json.load(sys.stdin)
print('iss:', t.get('iss'))
print('exp:', t.get('exp'), '→', datetime.fromtimestamp(t['exp']) if 'exp' in t else '')
print('email:', t.get('email'))
"
Testar token contra Supabase:
curl -s "https://{ref}.supabase.co/auth/v1/user" \
-H "Authorization: Bearer $TOKEN" \
-H "apikey: $VITE_SUPABASE_PUBLISHABLE_KEY"
Se "token is expired" → o token realmente expirou
`
Causa de token expirado na chegada: O OAuth implícito do Supabase gera tokens com exp baseado no relógio do servidor Supabase. Se o relógio do servidor VPS estiver dessincronizado, o token pode chegar já expirado. Solução: garantir NTP no VPS (timedatectl status) e usar PKCE flow (com ?code=), que é mais tolerante.
Se refreshSession também falhar com 401: O token de refresh pode não estar sendo salvo corretamente pelo Supabase. Nesse caso, a solução alternativa é mudar o Supabase Dashboard → Authentication → URL Configuration → Template de redirect para forçar PKCE flow em vez de implícito.
Se ainda assim o fluxo implícito persistir: No Supabase Dashboard, em Authentication → Providers → Google, verificar se o redirect URI está correto (https://{ref}.supabase.co/auth/v1/callback). URI errado = Supabase usa fluxo implícito como fallback.
setSession() Internamente Chama /auth/v1/user e Pode Retornar 401 Silencioso
Sintoma: setSession({ access_token, refresh_token }) retorna { error: null }, mas o próximo getSession() retorna null. Nenhum erro явный no console, mas GET /auth/v1/user retorna 401.
Causa-raiz: setSession() do @supabase/ssr chama getSession() internamente, que faz uma requisição a /auth/v1/user com o access_token. Se o token estiver expirado (clock skew, ou o token foi gerado há pouco tempo), a API retorna 401 e o session NÃO é definido — apesar de setSession() ter retornado error: null.
workaround — Gravar tokens direto no localStorage e recarregar:
`tsx
// Ao invés de setSession(), grave diretamente no localStorage da key correta
// e force um reload para o SDK do Supabase reidratar a sessão
const localStorageKey = sb-${window.location.hostname.replace(/\./g, '')}-auth-token;
// Ex: https://dauftiqcvgaydddoxqhh.supabase.co → sb-dauftiqcvgaydddoxqhh-auth-token
const sessionData = {
access_token: accessToken,
refresh_token: refreshToken,
expires_at: Math.floor(Date.now() / 1000) + 3600, // +1h (supabase não exige, mas define)
expires_in: 3600,
token_type: 'Bearer',
user: { id: userId, email: email }
};
localStorage.setItem(localStorageKey, JSON.stringify(sessionData));
// Limpar o hash da URL ANTES do reload
window.history.replaceState({}, '', window.location.pathname);
window.location.reload(); // Supabase SDK vai ler do localStorage no próximo load
`
Key correta do localStorage: O padrão é sb-{projectRef}-auth-token onde projectRef é o ID do projeto Supabase (o subdomínio em https://{ref}.supabase.co). Minus: dauftiqcvgaydddoxqhh → sb-dauftiqcvgaydddoxqhh-auth-token.
ssr: false na rota /auth: TanStack Start roda loaders no servidor onde window é undefined. Se a página de auth faz qualquer acesso ao hash ou localStorage no loader, vai falhar. Colocar ssr: false garante que a página rode 100% no cliente:
`tsx
export const Route = createFileRoute('/auth')({
component: AuthPage,
ssr: false, // ← força CSR, hash fica acessível no useEffect
});
`
Armadilha: Vite Rebuild do .env
Vite (na hora do npm run build) sobrescreve o arquivo .env com as variáveis VITE_SUPABASE_* do .env local. Se você configurou PUBLIC_SUPABASE_URL/PUBLIC_SUPABASE_KEY (sem VITE_), o Vite adiciona VITE_SUPABASE_URL e VITE_SUPABASE_KEY durante o build — sobrescrevendo o que você tinha configurado.
Solução: Modificar os arquivos fonte (vite.env.production, vite.env.development) em vez do .env diretamente. Ou, após o build, editar .env e fazer systemctl restart:
`bash
Após npm run build
sed -i '/VITE_SUPABASE/d' .env
systemctl restart
`
Build limpo (sempre fazer antes de rebuildar para testar OAuth):
`bash
rm -rf assets dist && npm run build && ln -sf dist/client/assets assets && systemctl restart
`
Usar ln -sf (symlink) em vez de cp -r — copiar assets resultados em arquivos obsoletos acumulados.
Loop Infinito de Redirect — O Problema de Timing entre AuthProvider e RequireAuth
Sintoma: Após login, o browser fica "piscando" infinitamente entre /auth e /. O login parece funcionar (token processado), mas o app nunca carrega a home.
Cadeia do loop:
1. /auth detecta sessão → window.location.href = "/" ou window.location.reload()
2. Na home, AuthProvider monta e chama getSession() → retorna null (SDK ainda não leu localStorage)
3. RequireAuth vê loading=true && user=null → redirect para /auth
4. /auth detecta sessão → window.location.href = "/" → reload
5. Volta ao passo 2 → loop infinito
Causa-raiz: supabase.auth.getSession() no AuthProvider é chamado ANTES do evento onAuthStateChange ser disparado. Em alguns casos, o SDK não consegue recuperar a sessão do localStorage a tempo, retornando null mesmo com tokens válidos armazenados.
⚠️ ARMAADILHA CRÍTICA: window.location.reload() NUNCA deve ser usado após salvar sessão no auth.tsx. O reload mantém o hash/código na URL, o módulo script roda de novo, salva novamente, reloada novamente — loop infinito. Sempre usar window.location.href = "/" (redirect limpo, não reload).
`tsx
// ✅ CORRETO — redirect simples limpa o estado da página
window.history.replaceState({}, "", window.location.pathname); // remove hash/código da URL
window.location.href = "/"; // navigation, não reload — AuthProvider vai ler localStorage corretamente
// ❌ ERRADO — reload mantém hash na URL, causa loop infinito
window.history.replaceState({}, "", window.location.pathname);
window.location.reload(); // ← NUNCA fazer reload após salvar sessão
`
Solução no AuthProvider — usar refreshSession() em vez de getSession() no mount:
`tsx
// No useEffect do AuthProvider — usar refreshSession, não getSession
supabase.auth.refreshSession().then(({ data }) => {
if (!mounted) return;
setSession(data.session);
setUser(data.session?.user ?? null);
if (data.session?.user) loadProfile(data.session.user.id);
setLoading(false);
});
`
Key correta do localStorage para storing sessão manualmente:
`
sb-dauftiqcvgaydddoxqhh-auth-token
`
Formato: sb-{projectRef}-auth-token — o projectRef é o ID do projeto em texto plano (ex: dauftiqcvgaydddoxqhh), NÃO uma string codificada em base64. O SDK source (@supabase/supabase-js/dist/umd/supabase.js) mostra: const key = 'sb-' + this.supabaseUrlLower + '-auth-token'.
OAuth callback — window.location.hash é VAZIO no useEffect mesmo com ssr: false
Sintoma: Hash #access_token=... aparece na URL do navegador, mas window.location.hash retorna "" dentro do useEffect do React. O callback OAuth nunca é processado.
Causa: TanStack Start processa a rota NO SERVIDOR primeiro (SSR streaming), depois envia o HTML para o cliente. Durante o SSR, window não existe. Quando o bundle JS carrega no cliente, o useEffect é executado, mas o hash já foi "consumido" ou está inacessível no contexto do streaming SSR.
Solução: NÃO usar useEffect para capturar o hash OAuth. Em vez disso, usar um módulo script inline que roda ANTES do React hydrate:
`tsx
// No auth.tsx — adicionar como primeiro filho do return, ANTES de qualquer componente React
return (
<>
{/ Script inline que captura hash ANTES do React carregar /}