„Jemand aus Deutschland hat vor 2 Stunden Pro gekauft.“ Diese Zeile findet man auf fast jeder SaaS-Landingpage – und oft ist sie komplett erfunden. Besser ist es, echte Bestellungen aus Ihrem Zahlungsanbieter anzuzeigen. Wenn Sie mit Polar verkaufen, können Sie aktuelle Bestellungen über die offizielle API abrufen, einen kleinen JSON-Endpunkt aus Ihrer Astro-Anwendung bereitstellen und dem Client ein dezentes Toast mit dem Label Verifiziert rendern lassen – damit Besucher wissen, dass das Signal aus Ihrem Checkout stammt und nicht aus einem Zufallsgenerator.
Hochwertige Starter-Kits mit integriertem Authentifizierungsfluss (Auth.js), Objekt-Uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrierten Zahlungen (Stripe, LemonSqueezy), E-Mail-Verifizierungsablauf (Resend, Postmark, Sendgrid) und viel mehr . Kompatibel mit jeder Datenbank (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
In dieser Anleitung binden Sie das Polar SDK in eine Astro-Server-Route ein, deployen auf Vercel und fügen eine schwebende Benachrichtigungskomponente hinzu, die durch aktuelle Verkäufe rotiert. Das Muster entspricht dem, das wir auf dieser Website verwenden: Geheimnisse nur auf dem Server, edge-freundliche Cache-Header und ein minimales Client-Skript.
Inhaltsverzeichnis
- Wie die Bausteine zusammenspielen
- Polar-Zugangstoken erstellen
- Astro mit dem Vercel-Adapter einrichten
- API-Route implementieren
- Umgebungsvariablen lokal und auf Vercel
- Schwebende Benachrichtigung für aktuelle Verkäufe hinzufügen
Voraussetzungen
Sie benötigen:
- Node.js 20 oder höher
- Ein Polar-Konto mit mindestens einem Produkt und (für aussagekräftige Demos) einigen Test- oder echten Bestellungen
- Ein Vercel-Konto, wenn Sie dort deployen möchten
Wie die Bausteine zusammenspielen
1. Browser → GET /api/sale (Ihre Astro-Route) → Erhält JSON: Produktname, ISO-Zeitstempel, optionales Rechnungsland-Label
2. Astro-API-Route (Node / Vercel Function) → Verwendet POLAR_ACCESS_TOKEN mit Polar SDK → Listet Bestellungen (neueste zuerst), mappt auf eine kleine öffentliche Struktur → Setzt Cache-Control für CDN-Caching
3. Polar API → Autoritative Liste von Bestellungen und PositionenDas Verifiziert-Badge in der UI signalisiert, dass die Nutzlast zum Anfragezeitpunkt serverseitig von Polar abgerufen wurde (oder aus einer gecachten Antwort stammt, die so erzeugt wurde). Halten Sie das Access Token aus Client-Bundles und Umgebungspräfixen fern.
Polar-Zugangstoken erstellen
- Öffnen Sie das Polar-Dashboard und melden Sie sich an.
- Gehen Sie zu den Settings Ihrer Organisation und suchen Sie Developers (die Bezeichnung kann leicht variieren).
- Erstellen Sie ein Token mit der Berechtigung orders:read.
- Kopieren Sie das Token einmalig und speichern Sie es sicher – Sie fügen es in Vercel und lokal in
.envalsPOLAR_ACCESS_TOKENein. (Committen Sie diesen Wert niemals in Git!)
Astro mit dem Vercel-Adapter einrichten
Erstellen Sie ein neues Projekt (oder verwenden Sie ein bestehendes mit output: 'server'):
npm create astro@latest polar-sale-badgecd polar-sale-badgeFügen Sie den Vercel-Adapter hinzu, damit API-Routen beim Deploy als Serverless Functions laufen:
npx astro add vercel --yesStellen Sie sicher, dass astro.config.mjs server-Output verwendet. Eine minimale Konfiguration sieht so aus:
import { defineConfig } from 'astro/config'import vercel from '@astrojs/vercel'
export default defineConfig({ output: 'server', adapter: vercel(),})Installieren Sie das Polar SDK:
npm install @polar-sh/sdkAPI-Route implementieren
Fügen Sie src/pages/api/sale.ts hinzu. Die wichtigen Details:
export const prerender = false, damit Astro diese URL nicht statisch zur Build-Zeit backt.- Instanziieren Sie
Polarmitimport.meta.env.POLAR_ACCESS_TOKEN. - Rufen Sie
polar.orders.listmit Sortierung neueste zuerst und einem kleinen limit auf, und mappen Sie jedes Element auf das, was die UI braucht (Produktname, createdAt, optionales Rechnungsland). - Geben Sie
Cache-Controlzurück, damit das CDN von Vercel das JSON einen Tag cachen kann und gleichzeitig Revalidierung erlaubt (max-age=0, s-maxage=86400ist ein sinnvoller Ausgangspunkt).
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', }, })}Nach Produkt filtern: In der Produktion möchten Sie eventuell nur bestimmte SKUs anzeigen (z. B. Namen, die Ihre Produktfamilie enthalten). Nach dem Mapping hält .filter((order) => order.name.includes('YourBrand')) das Toast relevant.
Ländernamen: Polar liefert einen Ländercode (wie DE, US usw.) im Feld billingAddress.country, nicht den vollständigen Ländernamen. Um den korrekten Ländernamen anzuzeigen, mappen Sie diesen Code mit einer Lookup-Tabelle – wie in der API-Route oben gezeigt.
Umgebungsvariablen lokal und auf Vercel
Erstellen Sie .env (und fügen Sie .env zu .gitignore hinzu, falls noch nicht geschehen):
POLAR_ACCESS_TOKEN="polar_oat_…"Für Vercel:
- Projekt → Settings → Environment Variables
- Fügen Sie
POLAR_ACCESS_TOKENfür Production hinzu (und Preview, wenn Sie PR-Deployments testen) - Deployen Sie erneut, damit der neue Wert verfügbar ist
Lokal starten:
npm run devBesuchen Sie http://localhost:4321/api/sale und prüfen Sie, ob Sie ein JSON-Array erhalten.
Schwebende Benachrichtigung für aktuelle Verkäufe hinzufügen
Erstellen Sie eine Komponente wie src/components/SalesPopup.astro mit folgendem Code:
---
---
<script> let popupTimeout let salesItems: any[] = [] let currentIndex = 0
function showSalePopup(item) { // Entfernen, falls bereits vorhanden 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"> Jemand aus <span class="font-bold text-branding">${item.billingCountry ? item.billingCountry : 'Unbekannt'}</span> hat <span class="font-bold text-branding">${item.name}</span> gekauft </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">Verifiziert</span></span> </div> ` document.body.appendChild(popup) // Einblenden setTimeout(() => { popup.classList.remove('translate-y-8', 'opacity-0', 'pointer-events-none') popup.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto') }, 10) // Nach 3 s entfernen 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 `vor ${diff} Sekunden` if (diff < 3600) return `vor ${Math.floor(diff / 60)} ${Math.floor(diff / 60) === 1 ? 'Minute' : 'Minuten'}` if (diff < 86400) return `vor ${Math.floor(diff / 3600)} ${Math.floor(diff / 3600) === 1 ? 'Stunde' : 'Stunden'}` return `vor ${Math.floor(diff / 86400)} ${Math.floor(diff / 86400) === 1 ? 'Tag' : 'Tagen'}` }
function fetchAndShow() { if (Array.isArray(salesItems) && salesItems.length > 0) { // Sequenziell, nicht zufällig 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>Der Code oben macht Folgendes:
fetch('/api/sale')nach dem Laden der Seite.- Speichert das Array im Speicher und rotiert Einträge in einem Intervall (z. B. alle 10 Sekunden).
- Rendert eine kleine fixierte Karte (unten rechts ist üblich) mit:
- Land (oder „Unbekannt“, wenn Sie keine grobe Standortangabe zeigen möchten)
- Produktname
- Relative Zeit („vor 3 Minuten“) aus dem ISO-
date - Eine Verifiziert-Zeile mit Schild-Icon, die verdeutlicht, dass die Daten von Polar stammen
Halten Sie das Skript schlank: schlichte DOM-APIs oder ein kleines timeAgo-Hilfsmittel reichen aus. Verlinken Sie die Karte mit Ihrem Pricing-Bereich, damit der Hinweis eine klare Handlungsaufforderung hat.
Wenn Sie strengere Datenschutzerwartungen erfüllen möchten, zeigen Sie nur die Region an oder lassen Sie Geografie ganz weg und zeigen nur Produkt und Zeit.
Ergebnis
Fazit
Sie haben jetzt einen Polar-gestützten Verkaufs-Feed: Die Astro-API-Route verwaltet das Geheimnis, Vercel liefert sie als Serverless Function mit CDN-Caching, und der Browser sieht nur anonymisierte Felder, die Sie freigeben. Passen Sie das Styling an Ihre Marke an, optimieren Sie Intervalle und Filter – und Sie erhalten glaubwürdigen Social Proof, ohne eine separate Analytics-Pipeline für „gefälschte“ Benachrichtigungen zu pflegen.