Arquitectura Híbrida: SSR con Astro + React Query para Apps Modernas

Cuando desarrollamos aplicaciones web modernas, a menudo nos enfrentamos a una disyuntiva: ¿elegimos Server-Side Rendering (SSR) para SEO y performance inicial, o optamos por una Single Page Application (SPA) para una experiencia de usuario fluida? La respuesta no tiene que ser una elección binaria. En este artículo, exploraremos cómo implementar una arquitectura híbrida que combina lo mejor de ambos mundos usando Astro, React Query y componentes React.
Desafíos Tradicionales
Las aplicaciones modernas necesitan:
- SEO-friendly: Contenido renderizado en el servidor para motores de búsqueda
- Performance inicial: Tiempo de carga rápido con contenido inmediato
- Experiencia interactiva: Navegación fluida sin recargas de página
- Gestión de estado eficiente: Cache inteligente y sincronización de datos
- Código mantenible: Arquitectura clara y escalable
Tradicionalmente, esto significaba elegir entre frameworks como Next.js (React SSR) o implementar SPAs con herramientas como React Query, pero perdiendo los beneficios del SSR.
Un problema particular surge cuando necesitamos:
- Renderizar contenido personalizado en el servidor (datos privados del usuario)
- Mantener la sesión de usuario entre SSR y cliente
- Usar herramientas de gestión de estado modernas como React Query
El flujo típico problemático se ve así:
SSR Page → Hydration → React Query Hook → API Call → ❌ Sin contexto de sesión
Arquitectura Híbrida con Astro
Antes de profundizar en la implementación, es crucial entender por qué elegir esta arquitectura particular y no alternativas más simples.
Problema 1: Contexto de Autenticación Perdido
En una arquitectura tradicional SPA + React Query, el flujo sería:
Browser → React Query → External API
Problema: El navegador no tiene acceso directo a tokens seguros (httpOnly cookies), y la API externa necesita autenticación.
Problema 2: SSR vs Client State Management
Si intentamos usar React Query directamente en SSR:
Astro SSR → React Query → ❌ No funciona (React Query es cliente-only)
Si usamos solo servicios en SSR:
Astro SSR → API Service → ✅ Funciona
Cliente → ❌ Sin cache ni gestión de estado moderna
Flujo Dual
Por eso se implementa un flujo dual que resuelve ambos problemas:
Para SSR (Prefetching):
Astro Page → API Service → External API
↓
React Query Cache (hydrated)
Para Cliente (Interactividad):
React Component → React Query → Proxy API → API Service → External API
Detalle de la Arquitectura
┌─────────────────────────────────────────────────────────────────┐
│ CLIENTE (Browser) │
├─────────────────┬───────────────────────┬─────────────────────┤
│ Astro SSR │ React Islands │ Internal APIs │
│ (Pages) │ (Interactive) │ (Proxy Layer) │
│ │ │ │
│ • SEO │ • React Query │ • Auth Context │
│ • Prefetching │ • Client State │ • Session Mgmt │
│ • Initial Data │ • User Interactions │ • Request Proxy │
│ │ │ │
│ fetchPosts() │ proxyFetchPosts() │ /api/posts │
│ ↓ │ ↓ │ ↓ │
└─────────────────┴───────────────────────┴─────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ SERVIDOR (Backend) │
├─────────────────────────────────────────────────────────────────┤
│ API Service Layer │
│ │
│ • Shared Business Logic │
│ • Authentication Headers │
│ • Error Handling │
│ • Request/Response Transformation │
│ │
│ ↓ │
├─────────────────────────────────────────────────────────────────┤
│ External API │
│ │
│ • Database Access │
│ • Core Business Logic │
│ • Authentication Validation │
└─────────────────────────────────────────────────────────────────┘
¿Por qué esta Arquitectura Específica?
1. Separation of Concerns (Separación de Responsabilidades)
SSR Layer (Astro):
- Responsabilidad: SEO, performance inicial, prefetching
- Herramientas: Servicios directos, sin overhead de React Query
- Ventaja: Acceso directo al contexto de servidor y sesión
Client Layer (React Islands):
- Responsabilidad: Interactividad, gestión de estado, experiencia de usuario
- Herramientas: React Query para cache, optimistic updates, background sync
- Ventaja: Experiencia moderna de SPA con gestión de estado avanzada
2. Proxy Layer: La Clave del Éxito
El proxy interno no es solo una "pasada de datos". Resuelve problemas fundamentales:
Sin Proxy (directo):
React Component → External API ❌
• No access to httpOnly cookies
• CORS issues
• Security vulnerabilities
• No request/response transformation
Con Proxy:
React Component → Internal API → External API ✅
• Server handles authentication
• Same-origin requests (no CORS)
• Centralized error handling
• Request/response transformation
3. Dual Service Pattern
Esta es la clave de la arquitectura - dos métodos para el mismo endpoint:
// Para SSR (servidor tiene contexto de sesión)
async function fetchPosts(page: number) {
return await apiService.get(`/posts?page=${page}`)
}
// Para Cliente (a través de proxy interno)
async function proxyFetchPosts(page: number) {
const response = await fetch(`/api/posts?page=${page}`, {
credentials: 'include' // Incluye cookies
})
return response.json()
}
¿Por qué dos métodos?
- SSR: Necesita acceso directo porque ya tiene contexto de sesión del servidor
- Cliente: Necesita proxy porque no puede acceder a tokens seguros directamente
- React Query: Puede hidratar datos del SSR y manejar actualizaciones del cliente seamlessly
Componentes Clave
1. API Service Layer
Primero, creamos una capa de servicios que maneja la autenticación:
// services/api.service.ts
import { AsyncLocalStorage } from 'node:async_hooks'
// Store para mantener contexto de sesión en SSR
let sessionStore: AsyncLocalStorage<{ accessToken?: string }> | undefined
if (import.meta.env.SSR) {
const mod = await import("node:async_hooks")
sessionStore = new mod.AsyncLocalStorage<{ accessToken?: string }>()
}
export class ApiService {
private getServerAccessToken(): string | undefined {
return sessionStore?.getStore?.()?.accessToken
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"content-type": "application/json",
}
// En SSR, usar token del sessionStore
const token = import.meta.env.SSR ? this.getServerAccessToken() : undefined
if (token) {
headers["authorization"] = `Bearer ${token}`
}
return headers
}
async get<T>(endpoint: string): Promise<T> {
const url = import.meta.env.SSR
? `${process.env.EXTERNAL_API_URL}/${endpoint}`
: `/api/${endpoint}` // Usar API proxy en cliente
const response = await fetch(url, {
method: "GET",
headers: this.buildHeaders(),
credentials: import.meta.env.SSR ? "omit" : "include",
})
if (!response.ok) {
throw new Error(`API Error: ${response.status}`)
}
return response.json()
}
}
2. Middleware para Contexto de Sesión
El middleware establece el contexto de sesión para todas las requests:
// middleware.ts
import { sessionStore } from './services/api.service'
import { defineMiddleware } from 'astro/middleware'
import { getSession } from 'auth-library' // Tu librería de auth
export const onRequest = defineMiddleware(async ({ request }, next) => {
try {
const session = await getSession(request)
// Establecer contexto de sesión para ApiService
if (sessionStore && session?.accessToken) {
return sessionStore.run(
{ accessToken: session.accessToken },
next
)
}
return next()
} catch (error) {
console.error('Session error:', error)
return next()
}
})
3. Servicios de Dominio
Creamos servicios específicos para cada dominio:
// services/posts.api.ts
import { ApiService } from './api.service'
const api = new ApiService()
export async function fetchPosts(page: number = 1, limit: number = 10) {
try {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString()
})
return await api.get(`/posts?${params.toString()}`)
} catch (error) {
console.error('Error fetching posts:', error)
return undefined
}
}
export async function fetchUserFeed(page: number = 1, limit: number = 10) {
try {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString()
})
return await api.get(`/users/feed?${params.toString()}`)
} catch (error) {
console.error('Error fetching user feed:', error)
return undefined
}
}
4. API Routes como Proxy
Las API routes actúan como proxy para el cliente:
// pages/api/posts/index.ts
import { fetchPosts } from '../../../services/posts.api'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ url }) => {
try {
const page = Number(url.searchParams.get("page") ?? 1)
const limit = Number(url.searchParams.get("limit") ?? 10)
const posts = await fetchPosts(page, limit)
return new Response(JSON.stringify(posts), {
status: 200,
headers: { "content-type": "application/json" },
})
} catch (error) {
console.error('[posts api]', error)
return new Response(null, { status: 500 })
}
}
5. Páginas Astro con Prefetching
Las páginas Astro manejan SSR y prefetching usando servicios directos:
---
// pages/dashboard.astro
import { fetchPosts, fetchUserFeed } from '../services/posts.api'
import { QueryClient, dehydrate } from '@tanstack/react-query'
import DashboardIsland from '../components/DashboardIsland'
import Layout from '../layouts/MainLayout.astro'
// Obtener parámetros de consulta
const page = Number(Astro.url.searchParams.get("page") ?? 1)
const limit = Number(Astro.url.searchParams.get("limit") ?? 10)
// Crear QueryClient para SSR
const ssrClient = new QueryClient()
// IMPORTANTE: Usar servicios directos en SSR
// porque tenemos acceso al contexto de sesión del servidor
await Promise.all([
ssrClient.prefetchQuery({
queryKey: ['posts', page, limit],
queryFn: () => fetchPosts(page, limit), // ← Servicio directo
}),
ssrClient.prefetchQuery({
queryKey: ['user-feed', page, limit],
queryFn: () => fetchUserFeed(page, limit), // ← Servicio directo
})
])
// Deshidratar estado para React Query
const dehydratedState = dehydrate(ssrClient)
---
<Layout>
<DashboardIsland
client:load
initialPage={page}
limit={limit}
dehydratedState={dehydratedState}
/>
</Layout>
¿Por qué servicios directos en SSR?
- Contexto de servidor disponible: El middleware ya estableció el contexto de sesión
- Performance: Sin overhead de HTTP requests internos
- Simplicidad: Acceso directo a la lógica de negocio
- Seguridad: Tokens manejados en memoria del servidor
6. React Islands con React Query
Los componentes React manejan la interactividad usando proxy functions:
// components/DashboardIsland.tsx
import { QueryClient, QueryClientProvider, HydrationBoundary, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
// ❗ IMPORTANTE: Proxy functions para el cliente
// porque no tenemos acceso directo a tokens seguros
async function fetchPostsProxy(page: number, limit: number) {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString()
})
// Llama al API interno que actúa como proxy
const response = await fetch(`/api/posts?${params.toString()}`, {
credentials: 'include' // Incluye cookies de autenticación
})
if (!response.ok) {
throw new Error('Failed to fetch posts')
}
return response.json()
}
function PostsList({ page, limit }: { page: number, limit: number }) {
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts', page, limit], // ← Misma key que en SSR!
queryFn: () => fetchPostsProxy(page, limit), // ← Proxy function
staleTime: 5 * 60 * 1000, // 5 minutos
})
if (isLoading) return <div>Loading posts...</div>
if (error) return <div>Error loading posts</div>
return (
<div>
{posts?.items?.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}
interface DashboardIslandProps {
initialPage: number
limit: number
dehydratedState: any
}
export default function DashboardIsland({
initialPage,
limit,
dehydratedState
}: DashboardIslandProps) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
{/* Los datos del SSR se hidratan automáticamente */}
<PostsList page={initialPage} limit={limit} />
</HydrationBoundary>
</QueryClientProvider>
)
}
¿Por qué proxy functions en el cliente?
- Seguridad: El cliente no puede acceder a tokens httpOnly
- CORS: Evita problemas de same-origin policy
- Consistencia: Misma interfaz que los servicios directos
- Flexibilidad: Permite transformaciones y validaciones
La Magia: SSR Prefetch + Client Hydration
La clave de esta arquitectura es que React Query no distingue entre datos que vienen del SSR vs. del cliente. Observa el flujo:
// 1. SSR (Astro): Prefetch con servicio directo
ssrClient.prefetchQuery({
queryKey: ['posts', 1, 10], // ← Key
queryFn: () => fetchPosts(1, 10), // ← Servicio directo
})
// 2. Cliente (React): Usa misma key, diferentes métodos
useQuery({
queryKey: ['posts', 1, 10], // ← MISMA Key!
queryFn: () => fetchPostsProxy(1, 10) // ← Proxy function
})
¿Qué sucede?
- SSR: Astro prefetch los datos usando servicios directos y los almacena en React Query cache
- Hydration: React Query encuentra los datos en cache y los usa inmediatamente
- Background: React Query puede refetch usando proxy functions sin que el usuario lo note
- Interacciones: Futuras interacciones usan proxy functions para mantener la cache actualizada
Beneficios de este enfoque:
- Carga inicial instantánea: Datos disponibles inmediatamente
- Experiencia fluida: Sin "loading" en hidratación
- Gestión de estado moderna: Cache, optimistic updates, background sync
- Arquitectura limpia: Separación clara entre SSR y cliente
Beneficios de Esta Arquitectura
1. SEO y Performance Optimizada
- Contenido renderizado en servidor para motores de búsqueda
- Tiempo de carga inicial rápido
- Datos prefetched disponibles inmediatamente
2. Experiencia de Usuario Fluida
- React Query maneja cache, refetch, y estados de loading
- Navegación sin recarga de página dentro de las islas
- Optimistic updates y background refetching
3. Gestión de Sesión Robusta
- Contexto de autenticación preservado entre SSR y cliente
- Middleware centralizado para manejo de sesiones
- API routes seguros con validación de sesión
4. Arquitectura Escalable
- Separación clara entre SSR y lógica de cliente
- Servicios reutilizables entre SSR y API routes
- Islands Architecture permite hidratación selectiva
5. Developer Experience
- Hot reload rápido con Astro
- TypeScript end-to-end
- Debugging simplificado con separación de responsabilidades
Consideraciones y Trade-offs
Ventajas
- Flexibilidad: Elegir SSR o SPA por página/componente
- Performance: Mejor que SPA puro, más interactivo que SSR tradicional
- SEO: Contenido indexable con experiencia moderna
- Mantenibilidad: Arquitectura clara y modular
Desventajas
- Complejidad inicial: Más setup que SPA o SSR puros
- Bundle size: React Query + React en islas
- Learning curve: Requiere entender Astro Islands
Conclusión
Esta arquitectura híbrida permite disfrutar de los beneficios del SSR para SEO y performance inicial, mientras que se mantiene la flexibilidad y experiencia de usuario de las SPAs modernas. React Query proporciona una gestión de estado poderosa que funciona seamlessly tanto con datos prefetched del SSR como con interacciones del cliente.
El resultado es una aplicación que se carga rápidamente, es SEO-friendly, y proporciona una experiencia de usuario moderna e interactiva. Para proyectos que requieren tanto performance como interactividad, esta approach representa una solución robusta y escalable.
La clave está en entender cuándo usar cada parte de la arquitectura: SSR para contenido inicial y SEO, React Islands para interactividad, y React Query para gestión de estado del cliente. Con esta separación clara de responsabilidades, podemos construir aplicaciones web que no comprometen en ningún aspecto.
Happy coding! :D
Photo by Ibrahim Mushan on Unsplash
Written with StackEdit.