Skip to content
IRC-CodingIRC-Coding
pwaservice-workerresponsive-web-appsofflineweb-manifestapp-entwicklung

App-Entwicklung: Responsive Web Apps, PWA und Service Worker

Progressive Web Apps (PWA) mit Service Workern, Offline-Fähigkeit und Responsive Design. Praxis-Tutorial mit Code-Beispielen.

I

IRC-Coding Team

12 min read
App-Entwicklung: Responsive Web Apps, PWA und Service Worker

App-Entwicklung: Responsive Web Apps, PWA und Service Worker

Progressive Web Apps (PWAs) kombinieren das Beste von Web- und nativen Apps: Sie laufen im Browser, sind installierbar, arbeiten offline und senden Push-Benachrichtigungen. Sie bieten die Reichweite des Webs mit der UX einer nativen App — ohne App-Store-Abnahmeprozess, ohne 30% Provision, ohne Plattform-spezifische Codebases.

In diesem Tutorial lernst Du, wie Du eine PWA baust — vom Manifest über Service Worker bis zu Offline-Daten und Push-Notifications.

TL;DR — PWAs in 90 Sekunden

Eine PWA ist eine Web-App, die installierbar ist, offline funktioniert und sich wie eine native App verhält.

---

Die 3 Säulen: Web App Manifest (Installierbarkeit), Service Worker (Offline/Caching), Responsive Design (alle Geräte).

Service Worker: JavaScript im Hintergrund, der Netzwerk-Requests abfängt und aus dem Cache beantwortet.

Caching-Strategien: Cache-First (statisch), Network-First (dynamisch), Stale-While-Revalidate (beides).

Ende der kompakten Erklärung!

Was ist eine Progressive Web App?

Eine PWA ist eine Web-Anwendung, die:

  • Installierbar ist — auf Desktop und Mobile, erscheint im App-Launcher / Home-Screen
  • Offline funktioniert — Service Worker cachen Ressourcen und Daten
  • Push-Benachrichtigungen empfangen kann — auch wenn die App geschlossen ist
  • Responsive ist — passt sich an jedes Gerät an (Mobile, Tablet, Desktop)
  • Sicher ist — HTTPS ist Pflicht für Service Worker
  • App-ähnliche UX bietet — Fullscreen, Splash-Screen, keine Browser-UI

PWA vs. Native App vs. Web-App

FeaturePWANative AppWeb-App
InstallationBrowser-PromptApp StoreNicht installierbar
Offline✅ (Service Worker)
Push-Notifications
Hardware-ZugriffEingeschränktVollSehr eingeschränkt
App-StoreOptionalPflichtNicht nötig
EntwicklungEine CodebasePro PlattformEine Codebase
UpdateAutomatisch (Browser)App Store ReviewAutomatisch
KostenKostenlos99$/Jahr (Apple), 25$ (Google)Kostenlos

Die drei Säulen einer PWA

1. Web App Manifest

Das Manifest ist eine JSON-Datei, die definiert, wie die App installiert wird und aussieht. Ohne Manifest keine Installation.

{
  "name": "Meine PWA",
  "short_name": "PWA",
  "description": "Eine Progressive Web App mit Offline-Funktionalität",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "Neue Notiz",
      "url": "/new",
      "icons": [{ "src": "/icons/shortcut.png", "sizes": "96x96" }]
    }
  ]
}

Wichtige Felder erklärt:

  • display: "standalone" — App läuft ohne Browser-UI (Adressleiste, Navigation)
  • theme_color — Farbe der Status-Bar auf Mobile
  • purpose: "maskable" — Icon kann auf verschiedene Formen zugeschnitten werden (Android)
  • shortcuts — Schnellaktionen im App-Launcher (Rechtsklick auf Desktop, Long-Press auf Mobile)

Einbindung im HTML:

<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#000000" />
<!-- iOS-spezifisch: -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />

Warum die iOS-spezifischen Meta-Tags? Safari ignoriert teilweise das Manifest. Die apple-* Tags stellen sicher, dass die App auf iOS korrekt installiert und dargestellt wird.

2. Service Worker — Das Herzstück

Der Service Worker ist ein JavaScript-Worker, der im Hintergrund läuft — unabhängig von der Web-Seite. Er fungiert als Proxy zwischen der App und dem Netzwerk: Jeder Request läuft durch den Service Worker, der entscheidet, ob er aus dem Cache oder vom Netzwerk beantwortet wird.

Service Worker Lifecycle:

Install → Activate → Fetch/Message Events (laufend)
   ↓         ↓
Caching   Alte Caches löschen
// sw.js — Der Service Worker

const CACHE_NAME = 'my-pwa-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192.png',
  '/offline.html'  // Fallback-Seite
];

// INSTALLATION: Assets vorab cachen (Pre-Caching)
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(ASSETS))
      .then(() => self.skipWaiting())  // Sofort aktivieren, nicht warten
  );
});

// AKTIVIERUNG: Alte Caches löschen (Versionierung)
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => {
      return Promise.all(
        keys
          .filter(key => key !== CACHE_NAME)  // Alle außer aktueller Cache
          .map(key => caches.delete(key))      // Löschen
      );
    }).then(() => self.clients.claim())  // Sofort Kontrolle übernehmen
  );
});

// FETCH: Cache-First-Strategie (für statische Assets)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response;  // Aus Cache
        }
        // Nicht im Cache → vom Netzwerk
        return fetch(event.request).catch(() => {
          // Netzwerk fehlt → Offline-Fallback
          if (event.request.mode === 'navigate') {
            return caches.match('/offline.html');
          }
        });
      })
  );
});

Was hier passiert:

  1. Install: Beim ersten Besuch werden alle statischen Assets (HTML, CSS, JS, Icons) in den Cache gespeichert. skipWaiting() sorgt dafür, dass der Service Worker sofort aktiv wird.
  2. Activate: Wenn eine neue Version des Service Workers deployed wird (CACHE_NAME = 'my-pwa-v2'), löscht der neue Service Worker alle alten Caches.
  3. Fetch: Jeder Request wird zuerst im Cache gesucht. Gefunden → aus Cache antworten (schnell, offline). Nicht gefunden → vom Netzwerk laden. Netzwerk fehlt → Offline-Seite zeigen.

3. Responsive Design

Responsive Design ist nicht PWA-spezifisch, aber essential — eine PWA muss auf Mobile, Tablet und Desktop gut aussehen.

/* Mobile-First: Standard-Styles für Mobile */
.container {
  width: 100%;
  padding: 1rem;
  font-size: 16px;
}

/* Navigation: Hamburger auf Mobile, horizontal auf Desktop */
.nav {
  display: flex;
  flex-direction: column;  /* Mobile: untereinander */
}

/* Tablet (768px+) */
@media (min-width: 768px) {
  .container {
    max-width: 720px;
    margin: 0 auto;
  }
  .nav {
    flex-direction: row;  /* Tablet: nebeneinander */
  }
}

/* Desktop (1024px+) */
@media (min-width: 1024px) {
  .container {
    max-width: 960px;
  }
}

/* Großer Desktop (1440px+) */
@media (min-width: 1440px) {
  .container {
    max-width: 1200px;
  }
}

Mobile-First-Prinzip: Starte mit Mobile-Styles (kleinste Bildschirme) und füge dann mit min-width Media Queries größere Bildschirme hinzu. Das ist effizienter als Desktop-First mit max-width, weil Mobile-Geräte weniger CSS verarbeiten müssen.

Service Worker registrieren

Der Service Worker muss aus der Haupt-App heraus registriert werden:

// app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(registration => {
        console.log('SW registriert:', registration.scope);
        
        // Update-Erkennung
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              // Neue Version verfügbar — User benachrichtigen
              showUpdateNotification();
            }
          });
        });
      })
      .catch(error => {
        console.error('SW Registrierung fehlgeschlagen:', error);
      });
  });
}

// Update anwenden
function applyUpdate() {
  navigator.serviceWorker.getRegistration().then(reg => {
    if (reg.waiting) {
      reg.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  });
  window.location.reload();
}

Wichtig:

  • Service Worker müssen über HTTPS ausgeliefert werden (Ausnahme: localhost für Entwicklung)
  • Der Pfad zum Service Worker bestimmt seinen Scope — /sw.js kontrolliert die gesamte Domain
  • Service Worker werden beim ersten Besuch installiert und bei jedem folgenden Besuch aus dem Cache geladen

Caching-Strategien — Welche für welchen Fall?

Die Wahl der richtigen Caching-Strategie ist entscheidend für die Performance und Offline-Fähigkeit Deiner PWA.

Cache-First (für statische Assets)

Antwortet immer aus dem Cache, wenn verfügbar. Nur wenn nicht im Cache, geht es ans Netzwerk.

// Ideal für: CSS, JS, Fonts, Icons — Dateien die sich selten ändern
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cached => cached || fetch(event.request))
  );
});

Wann verwenden? Statische Assets, die sich zwischen Deployments nicht ändern. Bei einem neuen Deployment wird der Cache-Name geändert und alte Caches werden gelöscht.

Network-First (für dynamische Inhalte)

Versucht zuerst das Netzwerk. Schlägt fehl (offline), fällt auf den Cache zurück.

// Ideal für: API-Requests, News-Feeds, User-Daten
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // Erfolgreiche Antwort in Cache speichern
        const clone = response.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
        return response;
      })
      .catch(() => {
        // Offline → aus Cache
        return caches.match(event.request);
      })
  );
});

Wann verwenden? Dynamische Inhalte, die immer aktuell sein sollen, aber offline noch lesbar sein können.

Stale-While-Revalidate (Beste von beiden)

Antwortet sofort aus dem Cache (schnell) und holt im Hintergrund eine neue Version (aktuell).

// Ideal für: Bilder, Fonts, nicht-kritische Assets
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      const fetchPromise = fetch(event.request).then(response => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
        return response;
      }).catch(() => cached);
      return cached || fetchPromise;
    })
  );
});

Wann verwenden? Assets, die sich manchmal ändern, aber bei denen eine leicht veraltete Version akzeptabel ist. Der User bekommt sofort eine Antwort und beim nächsten Besuch die aktuelle Version.

Strategie-Vergleich

StrategieGeschwindigkeitAktualitätOfflineEignung
Cache-FirstSehr schnellVeraltet bis UpdateStatische Assets
Network-FirstLangsam (Netzwerk)Immer aktuell✅ (alter Cache)Dynamische Inhalte
Stale-While-RevalidateSehr schnellEtwas veraltetBilder, Fonts

Multi-Strategie-Ansatz

In der Praxis nutzt Du verschiedene Strategien für verschiedene Request-Typen:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // API-Requests: Network-First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
    return;
  }
  
  // Bilder: Stale-While-Revalidate
  if (event.request.destination === 'image') {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }
  
  // Alles andere: Cache-First
  event.respondWith(cacheFirst(event.request));
});

Push-Benachrichtigungen

Push-Notifications ermöglichen es Deiner PWA, User auch dann zu erreichen, wenn die App geschlossen ist. Das erfordert einen Push-Service (z.B. Firebase Cloud Messaging, Web Push).

// 1. Berechtigung anfragen
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('User hat Notifications abgelehnt');
    return;
  }
  
  // 2. Push-Subscription erstellen
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,  // Notifications müssen sichtbar sein
    applicationServerKey: VAPID_PUBLIC_KEY  // Öffentlicher VAPID-Key
  });
  
  // 3. Subscription an Server senden
  await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
  
  console.log('Push-Subscription registriert');
}

// 4. Im Service Worker empfangen
self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
      actions: [
        { action: 'open', title: 'Öffnen' },
        { action: 'close', title: 'Schließen' }
      ]
    })
  );
});

// 5. Klick auf Notification behandeln
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

Was Du brauchst:

  • VAPID-Keys (generierbar mit npx web-push generate-vapid-keys)
  • Einen Server, der Push-Notifications sendet (z.B. mit web-push npm package)
  • HTTPS (Pflicht für Service Worker)

iOS-Einschränkung: Push-Notifications auf iOS funktionieren erst ab iOS 16.4 und nur, wenn die PWA zum Home-Screen hinzugefügt wurde.

Offline-Daten mit IndexedDB

Der Cache ist gut für statische Assets, aber nicht für strukturierte Daten. Für Offline-Daten (z.B. Todos, Notizen, User-Daten) nutzt Du IndexedDB.

// IndexedDB ist asynchron und callback-basiert — idb macht es Promise-basiert
// npm install idb
import { openDB } from 'idb';

const db = await openDB('my-pwa-db', 1, {
  upgrade(db) {
    // Object Store erstellen (wie eine Tabelle)
    const store = db.createObjectStore('todos', { keyPath: 'id' });
    // Index für schnellere Suchen
    store.createIndex('by-status', 'status');
    store.createIndex('by-date', 'createdAt');
  }
});

// Daten speichern (offline)
async function saveTodo(todo) {
  await db.put('todos', {
    id: crypto.randomUUID(),
    title: todo.title,
    status: 'pending',
    createdAt: Date.now()
  });
}

// Daten lesen
async function getTodos() {
  return await db.getAll('todos');
}

// Nach Status filtern
async function getPendingTodos() {
  return await db.getAllFromIndex('todos', 'by-status', 'pending');
}

// Daten löschen
async function deleteTodo(id) {
  await db.delete('todos', id);
}

Cache vs. IndexedDB:

  • Cache API: Für HTTP-Responses (HTML, CSS, JS, Bilder). Key = Request, Value = Response.
  • IndexedDB: Für strukturierte Daten (JSON-Objekte, User-Daten, Offline-Changes). Key = beliebiger Key, Value = beliebiges Objekt.

Background Sync (Offline-Änderungen synchronisieren)

Wenn der User offline Änderungen macht, können diese mit Background Sync automatisch synchronisiert werden, sobald wieder online:

// In der App: Sync registrieren
async function syncTodos() {
  const reg = await navigator.serviceWorker.ready;
  await reg.sync.register('sync-todos');
}

// Im Service Worker: Sync-Event behandeln
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-todos') {
    event.waitUntil(syncTodosWithServer());
  }
});

async function syncTodosWithServer() {
  const pendingTodos = await db.getAllFromIndex('todos', 'by-status', 'pending');
  for (const todo of pendingTodos) {
    await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo)
    });
    await db.put('todos', { ...todo, status: 'synced' });
  }
}

PWA mit Frameworks

Vite PWA Plugin (empfohlen für Vite-Projekte)

// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',  // Automatische Updates
      manifest: {
        name: 'Meine PWA',
        short_name: 'PWA',
        theme_color: '#000000',
        icons: [
          { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 3600 }
            }
          }
        ]
      }
    })
  ]
};

Vorteil: Vite PWA generiert den Service Worker automatisch mit Workbox. Du musst keinen manuellen Service Worker schreiben.

Next.js PWA

npx create-next-app@latest my-pwa --typescript
npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
});

module.exports = withPWA({});

Astro PWA

Astro hat eine offizielle PWA-Integration:

npx astro add astro-pwa

PWA testen

Lighthouse Audit

Lighthouse prüft PWA-Konformität und gibt einen Score:

npx lighthouse https://localhost:3000 --view --preset=pwa

Lighthouse prüft:

  • Manifest vorhanden und korrekt
  • Service Worker registriert
  • HTTPS aktiv
  • Responsive auf Mobile
  • Offline-Funktionalität
  • Installierbarkeit

Service Worker Debugging

Chrome DevTools → Application → Service Workers:

  • Status anzeigen (aktiv, wartend, installiert)
  • “Update on reload” für Entwicklung
  • “Bypass for network” um Service Worker zu deaktivieren
  • “Unregister” um Service Worker zu entfernen

Manueller Offline-Test

Chrome DevTools → Application → Service Workers → “Offline” checkbox aktivieren. Dann Seite neu laden und prüfen, ob Offline-Mode funktioniert.

App-Installation erkennen

Du kannst den Installations-Prompt abfangen und einen eigenen “Install”-Button anzeigen:

let deferredPrompt;

// Browser zeigt beforeinstallprompt an (vor dem automatischen Prompt)
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();  // Automatischen Prompt verhindern
  deferredPrompt = e;
  showInstallButton();  // Eigenen Button anzeigen
});

// User klickt auf Install-Button
installButton.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  
  deferredPrompt.prompt();  // Installations-Dialog zeigen
  const { outcome } = await deferredPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('User hat App installiert');
    hideInstallButton();
  } else {
    console.log('User hat Installation abgelehnt');
  }
  
  deferredPrompt = null;  // Prompt kann nur einmal verwendet werden
});

// App wurde installiert
window.addEventListener('appinstalled', () => {
  console.log('App erfolgreich installiert');
  // Analytics-Event senden
});

Wichtig: beforeinstallprompt wird nicht auf iOS ausgelöst. iOS-Nutzer müssen manuell “Zum Home-Bildschirm” wählen.

Prüfungsrelevante Punkte

  • PWA: Progressive Web App = installierbar + offline + responsive + sicher (HTTPS)
  • 3 Säulen: Web App Manifest (Installation), Service Worker (Offline/Caching), Responsive Design (alle Geräte)
  • Manifest: JSON-Datei mit name, icons, display, theme_color, start_url
  • Service Worker Lifecycle: Install (pre-cache) → Activate (alte Caches löschen) → Fetch (Requests abfangen)
  • Caching-Strategien: Cache-First (statisch), Network-First (dynamisch), Stale-While-Revalidate (beides)
  • Push-Notifications: VAPID-Keys, Push-Subscription, push Event im Service Worker
  • IndexedDB: Offline-Datenbank für strukturierte Daten (im Gegensatz zu Cache API für HTTP-Responses)
  • Background Sync: Automatische Synchronisierung von Offline-Änderungen
  • HTTPS: Pflicht für Service Worker (Ausnahme: localhost)
  • Testing: Lighthouse PWA Audit, Chrome DevTools Application Tab

FAQ

Sind PWAs ein Ersatz für native Apps? Für viele Anwendungsfälle ja — besonders Content-Apps, E-Commerce, Tools. Einschränkungen gibt es bei Hardware-Zugriff (Bluetooth, NFC, Sensoren), Hintergrund-Prozessen und App-Store-Präsenz. Für Spiele und Hardware-nahe Apps bleiben native Apps die bessere Wahl.

Brauche ich ein Framework für PWAs? Nein, PWAs funktionieren mit Vanilla JS — ein Manifest und ein Service Worker reichen. Frameworks (Vite PWA, Next-PWA) erleichtern aber den Setup und generieren den Service Worker automatisch mit Workbox.

Funktionieren PWAs auf iOS? Ja, mit Einschränkungen. Push-Notifications sind seit iOS 16.4 verfügbar. Background Sync wird nicht unterstützt. Der Installations-Prompt muss manuell über “Teilen → Zum Home-Bildschirm” erfolgen (kein beforeinstallprompt Event).

Wie aktualisiere ich meine PWA? Bei jedem Deploy: 1) Neue Assets hochladen, 2) Cache-Name im Service Worker erhöhen (z.B. v1v2), 3) Browser erkennt beim nächsten Besuch, dass sich der Service Worker geändert hat und installiert die neue Version. Mit skipWaiting() wird die neue Version sofort aktiv.

Was ist der Unterschied zwischen Cache API und IndexedDB? Cache API speichert HTTP-Responses (HTML, CSS, JS, Bilder) — Key ist der Request, Value die Response. IndexedDB speichert strukturierte Daten (JSON-Objekte) mit beliebigen Keys und Indizes. Nutze Cache API für statische Assets und IndexedDB für User-Daten und Offline-Changes.

Kann ich PWA und SPA (React/Vue/Svelte) kombinieren? Ja, das ist sogar der Normalfall. Die SPA läuft als Web-App, der Service Worker cached die SPA-Assets, und das Manifest macht sie installierbar. Vite PWA Plugin unterstützt React, Vue, Svelte und andere Frameworks out of the box.

Empfohlene Literatur

Web-Entwicklung

Bücher über React, Vue, Frontend und Backend

JavaScript: Das umfassende Handbuch. JavaScript lernen und verstehen von Philip Ackermann

JavaScript: Das umfassende Handbuch. JavaScript lernen und verstehen von Philip Ackermann

Bei Amazon ansehen

Affiliate-Link: Bei einem Kauf erhalten wir möglicherweise eine Provision.

Fullstack-Entwicklung: Das Handbuch für Webentwickler

Fullstack-Entwicklung: Das Handbuch für Webentwickler

Bei Amazon ansehen

Affiliate-Link: Bei einem Kauf erhalten wir möglicherweise eine Provision.

Zurück zum DEV Blog
Share:

Ähnliche Beiträge