«Alguien en Alemania acaba de comprar Pro hace 2 horas.» Esa frase aparece en casi todas las landing pages de SaaS y, a menudo, está completamente inventada. Un enfoque mejor es mostrar pedidos reales de su proveedor de pagos. Si vende con Polar, puede leer pedidos recientes a través de la API oficial, exponer un pequeño endpoint JSON desde su aplicación Astro y dejar que el cliente renderice un toast discreto con la etiqueta Verificado para que los visitantes sepan que la señal proviene de su checkout, no de un generador de números aleatorios.
Kits de inicio de alta calidad con flujo de autenticación integrada (Auth.js), carga de objetos (AWS, Clouflare R2, Firebase Storage, Supabase Storage), pagos integrados (Stripe, LemonSqueezy), flujo de verificación de correo electrónico (Resend, Postmark, Sendgrid) y mucho más . Compatible con cualquier base de datos (Redis, Postgres, MongoDB, SQLite, Firestore).
Get all 3 kits Bundle ↗- Next.js Starter Kit
- SvelteKit Starter Kit
- Astro Starter Kit
One-time license · Lifetime updates
En esta guía, conectará el Polar SDK a una ruta de servidor de Astro, lo desplegará en Vercel y añadirá un componente de notificación flotante que rota entre ventas recientes. El patrón coincide con el que usamos en este sitio: secretos solo en el servidor, cabeceras de caché compatibles con el edge y un script de cliente mínimo.
Tabla de contenidos
- Cómo encajan las piezas
- Crear un token de acceso de Polar
- Configurar Astro con el adaptador de Vercel
- Implementar la ruta API
- Variables de entorno localmente y en Vercel
- Añadir la notificación flotante de venta reciente
Requisitos previos
Necesitará:
- Node.js 20 o posterior
- Una cuenta de Polar con al menos un producto y (para demos significativas) algunos pedidos de prueba o reales
- Una cuenta de Vercel si desea desplegar allí
Cómo encajan las piezas
1. Navegador → GET /api/sale (su ruta Astro) → Recibe JSON: nombre del producto, marca de tiempo ISO, etiqueta opcional de país de facturación
2. Ruta API de Astro (Node / Vercel Function) → Usa POLAR_ACCESS_TOKEN con Polar SDK → Lista pedidos (más recientes primero), los mapea a una estructura pública pequeña → Establece Cache-Control para caché en CDN
3. API de Polar → Lista autoritativa de pedidos y líneas de artículoLa insignia Verificado en la interfaz comunica que la carga útil se obtuvo de Polar en el servidor en el momento de la solicitud (o proviene de una respuesta en caché generada de esa forma). Mantenga el token de acceso fuera de los bundles del cliente y de los prefijos de entorno.
Crear un token de acceso de Polar
- Abra el panel de Polar e inicie sesión.
- Vaya a Settings de su organización y busque Developers (el nombre puede variar ligeramente).
- Cree un token con permiso orders:read.
- Copie el token una vez y guárdelo de forma segura; lo añadirá a Vercel y al
.envlocal comoPOLAR_ACCESS_TOKEN. (¡Nunca suba este valor a git!)
Configurar Astro con el adaptador de Vercel
Cree un proyecto nuevo (o use uno existente con output: 'server'):
npm create astro@latest polar-sale-badgecd polar-sale-badgeAñada el adaptador de Vercel para que las rutas API se ejecuten como funciones serverless al desplegar:
npx astro add vercel --yesAsegúrese de que astro.config.mjs use salida server. Una configuración mínima se ve así:
import { defineConfig } from 'astro/config'import vercel from '@astrojs/vercel'
export default defineConfig({ output: 'server', adapter: vercel(),})Instale el Polar SDK:
npm install @polar-sh/sdkImplementar la ruta API
Añada src/pages/api/sale.ts. Los detalles importantes:
export const prerender = falsepara que Astro no intente pre-renderizar esta URL estáticamente en tiempo de compilación.- Instancie
Polarconimport.meta.env.POLAR_ACCESS_TOKEN. - Llame a
polar.orders.listcon ordenación más recientes primero y un limit pequeño, luego mapee cada elemento solo a lo que la interfaz necesita (nombre del producto, createdAt, país de facturación opcional). - Devuelva
Cache-Controlpara que la CDN de Vercel pueda almacenar el JSON en caché durante un día permitiendo revalidación (max-age=0, s-maxage=86400es un punto de partida razonable).
export const prerender = false
import { Polar } from '@polar-sh/sdk'
const countryCodeToName: { [key: string]: string } = { AF: 'Afghanistan',242 collapsed lines
AL: 'Albania', DZ: 'Algeria', AS: 'American Samoa', AD: 'Andorra', AO: 'Angola', AI: 'Anguilla', AQ: 'Antarctica', AG: 'Antigua and Barbuda', AR: 'Argentina', AM: 'Armenia', AW: 'Aruba', AU: 'Australia', AT: 'Austria', AZ: 'Azerbaijan', BS: 'Bahamas', BH: 'Bahrain', BD: 'Bangladesh', BB: 'Barbados', BY: 'Belarus', BE: 'Belgium', BZ: 'Belize', BJ: 'Benin', BM: 'Bermuda', BT: 'Bhutan', BO: 'Bolivia', BA: 'Bosnia and Herzegovina', BW: 'Botswana', BV: 'Bouvet Island', BR: 'Brazil', IO: 'British Indian Ocean Territory', BN: 'Brunei Darussalam', BG: 'Bulgaria', BF: 'Burkina Faso', BI: 'Burundi', KH: 'Cambodia', CM: 'Cameroon', CA: 'Canada', CV: 'Cape Verde', KY: 'Cayman Islands', CF: 'Central African Republic', TD: 'Chad', CL: 'Chile', CN: 'China', CX: 'Christmas Island', CC: 'Cocos (Keeling) Islands', CO: 'Colombia', KM: 'Comoros', CG: 'Congo', CD: 'Congo, Democratic Republic of the', CK: 'Cook Islands', CR: 'Costa Rica', CI: "Côte d'Ivoire", HR: 'Croatia', CU: 'Cuba', CY: 'Cyprus', CZ: 'Czech Republic', DK: 'Denmark', DJ: 'Djibouti', DM: 'Dominica', DO: 'Dominican Republic', EC: 'Ecuador', EG: 'Egypt', SV: 'El Salvador', GQ: 'Equatorial Guinea', ER: 'Eritrea', EE: 'Estonia', ET: 'Ethiopia', FK: 'Falkland Islands (Malvinas)', FO: 'Faroe Islands', FJ: 'Fiji', FI: 'Finland', FR: 'France', GF: 'French Guiana', PF: 'French Polynesia', TF: 'French Southern Territories', GA: 'Gabon', GM: 'Gambia', GE: 'Georgia', DE: 'Germany', GH: 'Ghana', GI: 'Gibraltar', GR: 'Greece', GL: 'Greenland', GD: 'Grenada', GP: 'Guadeloupe', GU: 'Guam', GT: 'Guatemala', GG: 'Guernsey', GN: 'Guinea', GW: 'Guinea-Bissau', GY: 'Guyana', HT: 'Haiti', HM: 'Heard Island and McDonald Islands', VA: 'Holy See (Vatican City State)', HN: 'Honduras', HK: 'Hong Kong', HU: 'Hungary', IS: 'Iceland', IN: 'India', ID: 'Indonesia', IR: 'Iran, Islamic Republic of', IQ: 'Iraq', IE: 'Ireland', IM: 'Isle of Man', IL: 'Israel', IT: 'Italy', JM: 'Jamaica', JP: 'Japan', JE: 'Jersey', JO: 'Jordan', KZ: 'Kazakhstan', KE: 'Kenya', KI: 'Kiribati', KP: "Korea, Democratic People's Republic of", KR: 'Korea, Republic of', KW: 'Kuwait', KG: 'Kyrgyzstan', LA: "Lao People's Democratic Republic", LV: 'Latvia', LB: 'Lebanon', LS: 'Lesotho', LR: 'Liberia', LY: 'Libyan Arab Jamahiriya', LI: 'Liechtenstein', LT: 'Lithuania', LU: 'Luxembourg', MO: 'Macao', MK: 'Macedonia, The Former Yugoslav Republic of', MG: 'Madagascar', MW: 'Malawi', MY: 'Malaysia', MV: 'Maldives', ML: 'Mali', MT: 'Malta', MH: 'Marshall Islands', MQ: 'Martinique', MR: 'Mauritania', MU: 'Mauritius', YT: 'Mayotte', MX: 'Mexico', FM: 'Micronesia, Federated States of', MD: 'Moldova, Republic of', MC: 'Monaco', MN: 'Mongolia', ME: 'Montenegro', MS: 'Montserrat', MA: 'Morocco', MZ: 'Mozambique', MM: 'Myanmar', NA: 'Namibia', NR: 'Nauru', NP: 'Nepal', NL: 'Netherlands', NC: 'New Caledonia', NZ: 'New Zealand', NI: 'Nicaragua', NE: 'Niger', NG: 'Nigeria', NU: 'Niue', NF: 'Norfolk Island', MP: 'Northern Mariana Islands', NO: 'Norway', OM: 'Oman', PK: 'Pakistan', PW: 'Palau', PS: 'Palestinian Territory, Occupied', PA: 'Panama', PG: 'Papua New Guinea', PY: 'Paraguay', PE: 'Peru', PH: 'Philippines', PN: 'Pitcairn', PL: 'Poland', PT: 'Portugal', PR: 'Puerto Rico', QA: 'Qatar', RE: 'Réunion', RO: 'Romania', RU: 'Russian Federation', RW: 'Rwanda', BL: 'Saint Barthélemy', SH: 'Saint Helena', KN: 'Saint Kitts and Nevis', LC: 'Saint Lucia', MF: 'Saint Martin (French part)', PM: 'Saint Pierre and Miquelon', VC: 'Saint Vincent and the Grenadines', WS: 'Samoa', SM: 'San Marino', ST: 'Sao Tome and Principe', SA: 'Saudi Arabia', SN: 'Senegal', RS: 'Serbia', SC: 'Seychelles', SL: 'Sierra Leone', SG: 'Singapore', SK: 'Slovakia', SI: 'Slovenia', SB: 'Solomon Islands', SO: 'Somalia', ZA: 'South Africa', GS: 'South Georgia and the South Sandwich Islands', ES: 'Spain', LK: 'Sri Lanka', SD: 'Sudan', SR: 'Suriname', SJ: 'Svalbard and Jan Mayen', SZ: 'Swaziland', SE: 'Sweden', CH: 'Switzerland', SY: 'Syrian Arab Republic', TW: 'Taiwan, Province of China', TJ: 'Tajikistan', TZ: 'Tanzania, United Republic of', TH: 'Thailand', TL: 'Timor-Leste', TG: 'Togo', TK: 'Tokelau', TO: 'Tonga', TT: 'Trinidad and Tobago', TN: 'Tunisia', TR: 'Turkey', TM: 'Turkmenistan', TC: 'Turks and Caicos Islands', TV: 'Tuvalu', UG: 'Uganda', UA: 'Ukraine', AE: 'United Arab Emirates', GB: 'United Kingdom', US: 'United States', UM: 'United States Minor Outlying Islands', UY: 'Uruguay', UZ: 'Uzbekistan', VU: 'Vanuatu', VE: 'Venezuela', VN: 'Viet Nam', VG: 'Virgin Islands, British', VI: 'Virgin Islands, U.S.', WF: 'Wallis and Futuna', EH: 'Western Sahara', YE: 'Yemen', ZM: 'Zambia', ZW: 'Zimbabwe',}
export async function GET() { const polar = new Polar({ accessToken: import.meta.env.POLAR_ACCESS_TOKEN, }) let orders: { date: string; billingCountry: string | undefined; name: string }[] = [] const result = await polar.orders.list({ sorting: ['-created_at'], limit: 10, }) for await (const page of result) { orders = page.result.items .map((item) => ({ name: item.product.name, date: item.createdAt.toISOString(), billingCountry: item.billingAddress?.country ? countryCodeToName[item.billingAddress.country] || item.billingAddress.country : undefined, })) .filter((order) => order.name.includes('LaunchFast')) .slice(0, 5) break } return new Response(JSON.stringify(orders), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=0, s-maxage=86400', }, })}Filtrar por producto: En producción puede que solo quiera ciertos SKU (por ejemplo, nombres que contengan su familia de productos). Tras el mapeo, .filter((order) => order.name.includes('YourBrand')) mantiene el toast relevante.
Nombres de países: Polar devuelve un código de país (como DE, US, etc.) en el campo billingAddress.country, no el nombre completo. Para mostrar el nombre correcto, mapee ese código con una tabla de consulta, como se muestra en la ruta API de ejemplo anterior.
Variables de entorno localmente y en Vercel
Cree .env (y añada .env a .gitignore si aún no está):
POLAR_ACCESS_TOKEN="polar_oat_…"Para Vercel:
- Proyecto → Settings → Environment Variables
- Añada
POLAR_ACCESS_TOKENpara Production (y Preview si prueba despliegues de PR) - Vuelva a desplegar para que el nuevo valor esté disponible
Ejecute localmente:
npm run devVisite http://localhost:4321/api/sale y confirme que recibe un array JSON.
Añadir la notificación flotante de venta reciente
Cree un componente como src/components/SalesPopup.astro con el siguiente código:
---
---
<script> let popupTimeout let salesItems: any[] = [] let currentIndex = 0
function showSalePopup(item) { // Eliminar si ya existe const existing = document.getElementById('sale-popup') if (existing) existing.remove() const popup = document.createElement('a') popup.id = 'sale-popup' popup.href = '/#pricing' popup.className = `fixed bottom-6 right-6 z-50 bg-white shadow-lg rounded-xl px-6 py-4 flex flex-col items-start min-w-[270px] max-w-xs border border-gray-100 transition-transform duration-500 translate-y-8 opacity-0 pointer-events-none` popup.innerHTML = ` <div class="text-sm text-gray-700"> Alguien en <span class="font-bold text-branding">${item.billingCountry ? item.billingCountry : 'Desconocido'}</span> compró <span class="font-bold text-branding">${item.name}</span> </div> <div class="mt-1 text-xs text-gray-500 flex items-center gap-2"> <span>${timeAgo(item.date)}</span> <span>| <svg class="inline-block size-3 text-branding" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M269.4 2.9C265.2 1 260.7 0 256 0s-9.2 1-13.4 2.9L54.3 82.8c-22 9.3-38.4 31-38.3 57.2c.5 99.2 41.3 280.7 213.6 363.2c16.7 8 36.1 8 52.8 0C454.7 420.7 495.5 239.2 496 140c.1-26.2-16.3-47.9-38.3-57.2L269.4 2.9zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"></path></svg> <span class="text-branding font-semibold">Verificado</span></span> </div> ` document.body.appendChild(popup) // Animar entrada setTimeout(() => { popup.classList.remove('translate-y-8', 'opacity-0', 'pointer-events-none') popup.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto') }, 10) // Eliminar tras 3 s clearTimeout(popupTimeout) popupTimeout = setTimeout(() => { popup.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto') popup.classList.add('translate-y-8', 'opacity-0', 'pointer-events-none') setTimeout(() => popup.remove(), 500) }, 3000) }
function timeAgo(dateStr) { const now = new Date() const date = new Date(dateStr) const diff = Math.floor((now.getTime() - date.getTime()) / 1000) if (diff < 60) return `hace ${diff} segundos` if (diff < 3600) return `hace ${Math.floor(diff / 60)} ${Math.floor(diff / 60) === 1 ? 'minuto' : 'minutos'}` if (diff < 86400) return `hace ${Math.floor(diff / 3600)} ${Math.floor(diff / 3600) === 1 ? 'hora' : 'horas'}` return `hace ${Math.floor(diff / 86400)} ${Math.floor(diff / 86400) === 1 ? 'día' : 'días'}` }
function fetchAndShow() { if (Array.isArray(salesItems) && salesItems.length > 0) { // Secuencial, no aleatorio const item = salesItems[currentIndex] if (item && item.billingCountry) showSalePopup(item) currentIndex = (currentIndex + 1) % salesItems.length } }
fetch('/api/sale') .then((res) => res.json()) .then((res) => { salesItems = Array.isArray(res) ? res : [] currentIndex = 0 fetchAndShow() setInterval(fetchAndShow, 10000) })</script>El código anterior hace lo siguiente:
fetch('/api/sale')tras cargar la página.- Almacena el array en memoria y rota entradas en un intervalo (por ejemplo, cada 10 segundos).
- Renderiza una tarjeta fija pequeña (abajo a la derecha es lo habitual) con:
- País (o «Desconocido» si prefiere no mostrar ubicación aproximada)
- Nombre del producto
- Tiempo relativo («hace 3 minutos») a partir del
dateISO - Una fila Verificado con un icono de escudo que refuerza que los datos provienen de Polar
Mantenga el script ligero: APIs DOM simples o un pequeño helper para timeAgo son suficientes. Enlace la tarjeta a su sección de precios para que el aviso tenga una llamada a la acción clara.
Si desea cumplir expectativas de privacidad más estrictas, muestre solo la región u omita la geografía por completo y muestre solo producto y tiempo.
Resultado
Conclusión
Ahora tiene un feed de ventas respaldado por Polar: la ruta API de Astro gestiona el secreto, Vercel la sirve como función serverless con caché en CDN, y el navegador solo ve los campos anonimizados que elija exponer. Adapte el estilo a su marca, ajuste intervalos y filtros, y obtendrá prueba social creíble sin mantener un pipeline de analítica aparte para notificaciones «falsas».