name: tanstack-start-vps-supabase-auth
description: Deploy Lovable/TanStack Start app to VPS with Supabase Auth — fix blank screen (SSR doesn't serve static files) and configure Google OAuth
category: devops
TanStack Start + Supabase Auth — VPS Deployment
Context
Deploy a Lovable/TanStack Start app to VPS with Supabase Auth (not Lovable Cloud auth).
Problem
App loads but shows blank screen — TanStack Start node-server target does SSR only, NOT static file serving. Browser can't load JS bundles.
Solution
1. Build (on local/ Lovable)
`
npm run build
`
Produces dist/server/ (SSR handler) and dist/client/ (static JS/CSS).
2. Sync outputs correctly
`bash
SSR handler
cp dist/server/server.js .output/server/index.mjs
cp -r dist/server/assets/* .output/server/assets/
Static assets
cp -r dist/client/assets/* .output/client/assets/
`
Verify manifest hash: grep index-*.js in .output/server/index.mjs and .output/client/assets/.
3. Node HTTP wrapper (server.js)
`javascript
import { fetchHandler } from './.output/server/index.mjs';
import { createServer } from 'http';
import { readFileSync, existsSync } from 'fs';
import { extname, join } from 'path';
const CLIENT_DIR = './.output/client';
const MIME_TYPES = {
'.js': 'application/javascript',
'.css': 'text/css',
'.html': 'text/html',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
const server = createServer((req, res) => {
const filePath = join(CLIENT_DIR, req.url.split('?')[0]);
if (existsSync(filePath) && MIME_TYPES[extname(filePath)]) {
const body = readFileSync(filePath);
res.writeHead(200, { 'Content-Type': MIME_TYPES[extname(filePath)] });
res.end(body);
return;
}
// SSR for everything else
fetchHandler(req, res);
});
server.listen(PORT, () => console.log(Running on :${PORT}));
`
4. Supabase Auth setup
- - Replace
@lovable.dev/cloud-auth-jswith@supabase/supabase-js - - Create
src/lib/supabase.ts: - - Login page:
- - Create callback route
src/routes/auth/callback.tsx: - - TanStack Start
node-servertarget = SSR only, no static serving - - Output structure:
dist/client/assets/anddist/server/assets/are separate - -
VITE_SUPABASE_URL+VITE_SUPABASE_ANON_KEY(not the management token) for client - - Supabase dashboard is hard to automate via browser (session cookies block cross-origin nav)
- - If dashboard inaccessible, use Supabase CLI with Personal Access Token:
supabase projects listafterexport SUPABASE_ACCESS_TOKEN=... - - Project exists in API even if account doesn't have dashboard access (project may belong to different org)
- - Static file serving: ✅ Fixed
- - App loading: ✅ Works
- - Google OAuth: ⚠️ Needs Supabase dashboard config (client ID/secret + redirect URL)
`typescript
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseKey)
`
`typescript
supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://YOUR_DOMAIN/auth/callback'
}
})
`
`typescript
import { createFileRoute } from '@tanstack/react-router'
import { useEffect } from 'react'
import { supabase } from '@/lib/supabase'
import { useNavigate } from '@tanstack/react-router'
export const Route = createFileRoute('/auth/callback')({
component: Callback,
})
export function Callback() {
const navigate = useNavigate()
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) navigate({ to: '/dashboard' })
else navigate({ to: '/login' })
})
}, [])
return
}
`
5. Supabase dashboard config (manual)
Need to configure in Supabase dashboard:
1. Authentication → Providers → Google: Enable + add Google OAuth client ID/secret
2. Authentication → URL Configuration:
- Site URL: https://YOUR_DOMAIN
- Redirect URLs: https://YOUR_DOMAIN/auth/callback
6. VPS systemd service
`ini
[Unit]
Description=Documents App
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/www/documentos/deploy-nodejs/deploy-vps-nodejs/app
ExecStart=/usr/bin/node server.js
Restart=on-failure
User=root
[Install]
WantedBy=multi-user.target
`
7. OAuth callback route — SSR CRITICAL FIX
PROBLEMA: Supabase usa fluxo implícito (hash fragment #access_token=...), não PKCE (?code=). Quando TanStack Start faz SSR da rota /auth, window.location.hash é undefined no servidor — o hash chega na URL do navegador mas desaparece antes do useEffect rodar. Resultado: login trava na página /auth e volta pro login.
SOLUÇÃO: Adicionar ssr: false na definição da rota:
`typescript
export const Route = createFileRoute("/auth")({
ssr: false, // ← CRÍTICO: impede SSR questrip o hash OAuth
component: AuthPage,
});
`
Por que funciona: Com ssr: false, TanStack Start NÃO renderiza a página no servidor. O navegador recebe HTML mínimo e executa TODO o JavaScript client-side — o hash #access_token=... está lá quando useEffect executa.
Supabase client config (detectSessionInUrl: false é obrigatório):
`typescript
// client.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from './types'
export const supabase = createClient
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
{
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false, // ← NÃO deixa SDK consumir hash antes de você
}
}
)
`
Callback useEffect (no route /auth):
`typescript
useEffect(() => {
const hash = window.location.hash
if (hash.includes('access_token=')) {
const params = new URLSearchParams(hash.substring(1))
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
if (accessToken) {
supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken || '',
}).then(() => {
window.history.replaceState({}, '', '/auth') // limpa hash da URL
window.location.href = '/' // redirect client-side
})
}
}
}, [])
`
Verificação: Depois do fix, o console do navegador (F12) ao carregar /auth após OAuth deve mostrar:
`
[Auth] Hash length: 347 (não 0)
[Auth] Token received in hash. accessToken present: true
[Auth] Session saved, redirecting to /...
`
NÃO USA navigate() do TanStack Router no callback — window.location.href = '/' é mais confiável durante processamento OAuth.