Von der Datenbank ins DOM: Die Architektur einer skalierbaren Career-Page-Engine auf Google Cloud

Ein Deep Dive in den Bau eines statischen Seitengenerators mit React, Node.js, Webflow, Cheerio und Google Cloud Infrastructure.Alex FriedlDec 16, 2025
SSG - static site generator

Einführung

In der modernen Recruiting-Welt ist Geschwindigkeit Währung, aber Design ist das Verkaufsargument. Für Stahlhart Recruiting GmbH standen wir vor einer komplexen Herausforderung: Wie ermöglichen wir es, hunderte von individuellen, hochperformanten und SEO-optimierten Karriereseiten für Kunden zu hosten, ohne für jede Seite einen Entwickler abzustellen und ohne die Performance eines klassischen CMS wie WordPress in Kauf nehmen zu müssen?

Die Antwort war der Bau einer hybriden Plattform: Ein Partner Portal auf Basis eines modernen React-Stacks zur Datenverwaltung und eine Rendering-Engine, die Webflow-Templates mittels Node.js und Cheerio dynamisch mit Leben füllt. Das Ergebnis ist eine Symbiose aus "Low-Code" (für das Design) und "High-Code" (für die Logik und Skalierung).

Dieser Artikel beleuchtet die technische Reise, die Architekturentscheidungen und die tiefen Implementierungsdetails dieses Systems – von der Postgres-Datenbank über die DOM-Manipulation mittels Cheerio bis hin zur automatisierten Google Cloud Pipeline.


1. Die Architektur: Nx Monorepo als Fundament

Das System musste zwei Welten vereinen: Die Flexibilität eines CMS für Recruiter und die technische Strenge einer modernen Web-Applikation. Wir entschieden uns für eine Nx Monorepo Architektur, um Frontend, Backend und geteilte Bibliotheken effizient zu verwalten.

Der Tech-Stack* Frontend (Partner Portal): React, Vite, Tailwind CSS. Hier verwalten Recruiter und Kunden die Inhalte.

  • Backend (API): Node.js mit Koa, das als Schnittstelle zur Datenbank und als Trigger für die Generierung dient.
  • Datenbank: PostgreSQL mit Drizzle ORM.
  • Template Engine: Webflow (für das visuelle Design) exportiert als statisches HTML.
  • Generator Core: Node.js Skripte mit Cheerio zur DOM-Manipulation.
  • Infrastruktur: Google Cloud Platform (Storage, Tasks, Compute Engine, Logging).

Ein Blick in unsere package.json Struktur zeigt die Trennung der Verantwortlichkeiten im Monorepo:

json
// root package.json (Auszug) { "name": "@sr-partner-portal/root", "workspaces": [ "apps/*", "libs/*" ], "dependencies": { "@sr-partner-portal/api": "workspace:*", "@sr-partner-portal/app": "workspace:*", "@sr-partner-portal/common": "workspace:*", "@sr-partner-portal/db": "workspace:*" } }

Diese Struktur erlaubt es uns, Typen (Interfaces, DTOs) im @sr-partner-portal/common Package zu definieren und sowohl im React-Frontend als auch im Node.js-Backend zu nutzen. Wenn sich das Datenmodell einer Stellenanzeige ändert, bricht der Build sofort, wenn Frontend oder Backend nicht nachgezogen werden.


2. Das Datenmodell: Flexibilität durch JSONB und Drizzle

Eine der größten Herausforderungen war die schiere Menge an konfigurierbaren Optionen. Eine Karriereseite ist nicht nur Text. Es sind Farben (primary, accent), Typografie, Aktivierungsstatus von Sektionen (z.B. "Arbeitgeberfilm", "Benefits"), Bildergalerien und komplexe Job-Listen.

Anstatt hunderte von Spalten in einer SQL-Tabelle zu erstellen, nutzten wir die Stärke von PostgreSQL JSONB Spalten in Kombination mit Drizzle ORM.

Die Career-Entität

Wir speichern die gesamte Konfiguration einer Karriereseite in einem strukturierten JSON-Objekt. Hier ein Auszug aus dem tatsächlichen Datenmodell, das im Frontend bearbeitet wird:

json
{ "career": { "design": { "colors": { "sectionA": { "button": { "text": "Button", "color": "#1c72cd" }, "background": { "text": "Hintergrund", "color": "#ffffff" } } }, "fontFamily": "Inter" }, "general": { "sections": [ { "id": "hero", "isActive": true, "pageId": "jobs" }, { "id": "arbeitgeberfilm", "isActive": false, "pageId": "jobs" }, { "id": "vorteile", "isActive": true, "pageId": "jobs" } ] }, "jobs": { "benefits": [ { "icon": "work", "title": "Flexible Zeiten" }, { "icon": "home", "title": "Homeoffice" } ] } } }

Type-Safe Updates mit Drizzle und SQL-Injection

Das Aktualisieren tiefer JSON-Pfade in SQL kann schmerzhaft sein. Um dies typsicher und performant zu lösen, haben wir im CareerService eine Abstraktion gebaut, die Drizzles SQL-Template-Strings nutzt, um jsonb_set Operationen nativ auf der Datenbank auszuführen. Das verhindert Race Conditions, bei denen das gesamte JSON-Objekt überschrieben würde.

Hier ist die Implementierung der makeJsonbSet Funktion aus unserem CareerService:

typescript
// services/career-service.ts function makeJsonbSet<T extends keyof typeof dbTables.careerSettings>( category: T, field: string, value: unknown, ) { const prefix = `${category}.`; const stripped = field.startsWith(prefix) ? field.slice(prefix.length) : field; // Zerlegt den Pfad "design.colors.sectionA" in ein Array für Postgres const parts = stripped.split("."); const pathArray = `ARRAY[${parts.map((part) => `'${part}'`).join(", ")}]::text[]`; // Generiert natives SQL für ein atomares Update return drizzleOrm.sql`jsonb_set( ${dbTables.careerSettings[category]}, ${drizzleOrm.sql.raw(pathArray)}, ${drizzleOrm.sql`${JSON.stringify(value)}`}::jsonb, true )`; }

Und so wird diese Funktion beim Hochladen eines Bildes verwendet, um nur den Pfad zum Bild zu aktualisieren, ohne den Rest der Sektion zu berühren:

typescript
// services/career-service.ts (uploadImage) const newKey = uploadMeta.path; const setJson = makeJsonbSet( category as keyof typeof dbTables.careerSettings, normalizedField, // z.B. "general.selectionPageImage" newKey, ); const [updated] = await tx .update(dbTables.careerSettings) .set({ [category]: setJson }) .where(drizzleOrm.eq(dbTables.careerSettings.customer_id, customerId)) .returning();

3. Die Core Engine: Webflow trifft Cheerio

Der Kern des Projekts ist das populate.ts Skript. Hier geschieht die Magie. Wir nehmen ein statisches Webflow-Template, das wir mit speziellen data-attributes (data-sh-id) versehen haben, und "verheiraten" es mit den Daten aus der Postgres-Datenbank.

Warum Cheerio?

Wir nutzen Cheerio statt Puppeteer oder Playwright. Cheerio ist eine serverseitige Implementierung von jQuery. Da wir keine Screenshots machen, sondern HTML manipulieren, ist Cheerio um den Faktor 100 schneller, da es keinen Headless Browser starten muss. Es parst den String einfach in einen Virtual DOM.

Das Mapping-System: dataShIdToJsonPath

Um den Code wartbar zu halten, haben wir die Logik (HTML-Traversierung) von der Konfiguration (welches Feld gehört wohin) getrennt. Ein zentrales Mapping-Objekt verbindet data-sh-id Attribute mit JSON-Pfaden:

typescript
// utilities/mapping.ts export const dataShIdToJsonPath = { [DataShId.HeroTitle]: "career.jobs.introduction.title", [DataShId.ContactEmail]: "career.jobs.contacts.email", [DataShId.SocialMediaInstagramLink]: "career.global.instagramUsername", [DataShId.HeroStartImage]: "company.startScreenImagePath", // ... hunderte weitere Mappings };

Verarbeitung von "Leaf Tags" (Einfache Felder)

Die Funktion processLeafTags ist das Arbeitspferd. Sie iteriert über alle Elemente im DOM, prüft das Mapping und injiziert den Inhalt. Dabei müssen wir Sonderfälle wie Bilder (src, srcset), Links (href, mailto:) oder HTML-Content unterscheiden.

Hier ein gekürzter Einblick in die Logik:

typescript
// populate.ts function processLeafTags($: cheerio.CheerioAPI, data: any, reports: string[]) { $("[data-sh-id]").each((_, el) => { const $el = $(el); const dsid = $el.attr("data-sh-id") as DataShId; // Templates überspringen wir hier, die werden separat behandelt if (dsid.endsWith("-template")) return; // Wert aus dem tiefen JSON-Objekt holen const jsonPath = dataShIdToJsonPath[dsid]; const value = getValueAtPath(data, jsonPath); // Sonderfall: Links (E-Mail, Telefon, Social Media) if (dsid === DataShId.ContactEmailAddress) { const emailStr = String(value); $el.attr("href", `mailto:${emailStr}`); $el.text(emailStr); return; } // Sonderfall: Bilder if ($el.is("img")) { const imgPath = IMG_BASE_URL + String(value); $el.attr("src", imgPath); // Wichtig für Responsive Design: srcset muss auch angepasst werden $el.attr("srcset", imgPath); $el.attr("alt", `Karriere bei ${data.company.name}`); return; } // Standard: Text ersetzen $el.text(String(value)); }); }

Die Herausforderung dynamischer Listen (Arrays)

Webflow liefert statisches HTML. Wenn ein Kunde 5 Vorteile (Benefits) hat, das Template aber nur eine Beispiel-Karte zeigt, müssen wir das Template klonen.

Unsere Funktion processGenericArrayTemplates löst das elegant:

  1. Sie sucht Container mit -template Suffix im data-sh-id.
  2. Sie extrahiert das innere HTML als Blaupause.
  3. Sie iteriert über das Array aus der Datenbank.
  4. Für jeden Eintrag wird ein Klon erstellt, befüllt und angehängt.
  5. Das Original-Template wird aus dem DOM entfernt.
typescript
// populate.ts - Generische Array Verarbeitung // ... const wrapperDsid = DataShId.BenefitsCardTemplate; const $wrapper = $(`[data-sh-id='${wrapperDsid}']`).first(); const itemsArray = getValueAtPath(data, "career.jobs.benefits"); itemsArray.forEach((itemObj, idx) => { // 1. Klonen des inneren Templates const $cardClone = $wrapper.find("[data-sh-id='benefits__card']").clone(); // 2. Icon setzen (Mapping zu Material Icons) const iconName = itemObj.icon.toLowerCase().replace(/\s+/g, "_"); const $iconNode = $cardClone.find("[data-sh-id='benefits__card-icon']"); // Wir ersetzen das Webflow-Bild durch einen Font-Icon Span für Flexibilität const $materialIcon = $("<span>") .addClass("material-symbols-outlined") .text(iconName); $iconNode.replaceWith($materialIcon); // 3. Titel setzen $cardClone.find(`[data-sh-id='${DataShId.BenefitsCardTitle}']`).text(itemObj.title); // 4. Klon einfügen $wrapper.parent().append($cardClone); }); // 5. Template aufräumen $wrapper.remove();

4. Advanced Features: Mehr als nur statischer Text

Das Projekt erforderte Funktionalitäten, die über reines "Suchen und Ersetzen" hinausgehen. Wir mussten echte Web-App-Features in statische Seiten injizieren.

A. Das "Zebra"-Problem: Intelligente Sektions-Sichtbarkeit

Kunden können Sektionen (z.B. "Unser Team") deaktivieren. Wenn das Design ein Zebra-Muster vorsieht (Weiß - Grau - Weiß - Grau) und der Kunde eine weiße Sektion deaktiviert, folgen zwei graue aufeinander. Das sieht kaputt aus.

Wir haben eine Logik implementiert, die vor dem Rendern prüft, welche Sektionen aktiv sind, und die CSS-Klassen (w-variant-base vs. w-variant-inverted) dynamisch umschreibt.

typescript
// populate.ts function processSectionVisibility($: cheerio.CheerioAPI, data: any) { const sections = data.career.general.sections; const visibleSectionIds = []; // 1. Sammeln aller aktiven Sektionen sections.forEach(section => { if (section.isActive) visibleSectionIds.push(section.id); }); // 2. DOM Manipulation basierend auf Index visibleSectionIds.forEach((sectionId, index) => { const isEven = index % 2 === 0; const requiredVariant = isEven ? "base" : "inverted"; const $section = $(`[data-sh-id="${sectionId}__section"]`); // Prüfen, ob die Sektion eine Variante hat (data-wf--variant) // und CSS Klasse austauschen, falls nötig $section.attr("data-wf--variant", requiredVariant); // Entfernen der nicht benötigten Variante aus dem DOM (Webflow exportiert oft beide) $section.find(`[data-wf--variant="${isEven ? 'inverted' : 'base'}"]`).remove(); }); }

B. Das dynamische Bewerbungsformular (File Upload Hijacking)

Ein statisches Formular nützt im Recruiting nichts. Webflow-Formulare sind limitiert. Wir mussten das Formular im DOM "kapern", um Datei-Uploads, Validierung und API-Kommunikation zu ermöglichen.

Das Skript processApplicationForm wandelt ein einfaches Textfeld (input[name="Files"]) in ein voll funktionsfähiges File-Upload-Feld um und injiziert ein komplexes Client-Side Skript.

typescript
// populate.ts - Auszug aus processApplicationForm // 1. Formular Attribute für API-Versand vorbereiten const $form = $("#wf-form-Bewerbung-Form"); $form.attr("action", "javascript:void(0);"); // Verhindert Webflow-Submit $form.attr("enctype", "multipart/form-data"); // 2. Input-Feld Transformation const $filesField = $form.find('input[name="Files"]'); $filesField.attr("type", "file"); $filesField.attr("multiple", "true"); $filesField.attr("accept", ".pdf,.doc,.docx,.jpg"); // 3. Client-Side Script Injection (Der eigentliche "Treiber") const formScript = ` <script> document.addEventListener('DOMContentLoaded', function() { const form = document.getElementById('wf-form-Bewerbung-Form'); form.addEventListener('submit', async function(e) { e.preventDefault(); const formData = new FormData(); // Daten sammeln const fileInput = form.querySelector('input[name="files"]'); for (let i = 0; i < fileInput.files.length; i++) { formData.append('files', fileInput.files[i]); } formData.append('name', form.querySelector('input[name="name"]').value); // API Aufruf an unser Backend try { const response = await fetch('https://api.partner-portal.stahlhart-recruiting.de/career-settings/send-application', { method: 'POST', body: formData }); if (response.ok) { window.location.href = '/vielen-dank'; } } catch (err) { console.error(err); } }); }); </script> `; $("body").append(formScript);

C. Multi-Location Google Maps

Viele Kunden haben mehrere Standorte. Ein einfaches Iframe reicht nicht. Wir prüfen im Backend, wie viele Standorte Koordinaten haben.

  • 1 Standort: Wir generieren eine Iframe-URL und setzen sie als src.
  • >1 Standorte: Wir entfernen das Iframe und injizieren die Google Maps JavaScript API. Wir generieren dynamisch ein JSON-Array der Standorte und schreiben eine initMap Funktion direkt in das HTML, die Marker und Cluster erstellt.

5. Google Cloud Infrastruktur: Das Rückgrat des Systems

Damit dieser Generator performant und sicher läuft, setzen wir voll auf die Google Cloud Platform (GCP). Die Verwaltung erfolgt dabei nicht manuell, sondern über moderne Cloud Tools und Pipelines.

Cloud Tasks: Asynchrone Generierung

Die Generierung einer kompletten Karriereseite (mit oft 50+ Unterseiten für einzelne Jobs) ist rechenintensiv und dauert einige Sekunden. Ein synchroner API-Aufruf würde hier ins Timeout laufen oder den Server blockieren.

Wir nutzen Google Cloud Tasks. Der Flow sieht so aus:

  1. Trigger: Der User klickt "Publish".
  2. API: Die API validiert den Request und erstellt sofort einen Task.
  3. Worker: Cloud Tasks ruft unseren Worker-Service (den Generator) auf.

Hier ist der Code aus unserem Router, der je nach Umgebung entscheidet (Development vs. Production):

typescript
// routes/career-page.ts careerPageRouter.post( apiRoutes(true).careerPage().generate().build(), // ... Middlewares für Auth async (ctx) => { if (checkIsDevelopmentEnvironment()) { // Im Dev-Mode direkt ausführen für einfacheres Debugging await CareerPagesService.generateCareerPage({ customerId: ctx.request.body.customerId, }); } else { // In Production: Task in die Queue schieben await GoogleCloudTaskService.createCareerPageGenerationTask({ customerId: ctx.request.body.customerId, }); } ctx.response.status = 201; ctx.body = { success: true, message: "Generation queued" }; }, );

Der GoogleCloudTaskService nutzt das @google-cloud/tasks SDK, um den HTTP-Request zu kapseln, der später den eigentlichen Generator-Endpunkt aufruft. Das bietet uns automatische Retries und Rate Limiting "out of the box".

Hosting & Deployment (Cloud Storage)

Das Ergebnis der Generierung sind reine HTML, CSS und JS Dateien. Es wäre ineffizient, diese über einen Node.js Server auszuliefern. Stattdessen pushen wir sie in einen Google Cloud Storage (GCS) Bucket.

Der CareerPagesService übernimmt den Upload und setzt dabei wichtige Header für Caching und Content-Types:

typescript
// services/career-pages-service.ts const uploadDirectory = async (dirPath: string, relativePath = "") => { for (const item of fs.readdirSync(dirPath)) { const fullPath = path.join(dirPath, item); if (fs.statSync(fullPath).isDirectory()) { await uploadDirectory(fullPath, `${relativePath}/${item}`); } else { // Content-Type erraten (wichtig für Browser!) const contentType = getContentType(fullPath); const content = fs.createReadStream(fullPath); await GcloudBucketService.uploadCareerPageFile( customerId, relativePath ? `${relativePath}/${item}` : item, content, contentType, ); } } };

DNS & Load Balancing

Wie kommen die Besucher auf die Seite? Die Karriereseiten laufen unter Subdomains (z.B. karriere.kunde-a.de). Wir nutzen Google Cloud Load Balancing.

  1. Der Load Balancer empfängt den Traffic (HTTPS).
  2. Ein SSL-Zertifikatsmanager verwaltet automatisch Zertifikate für hunderte von Domains.
  3. Ein "Backend Bucket" routet den Traffic basierend auf dem Pfad oder Host-Header in den richtigen Ordner im GCS-Bucket.

Dies ermöglicht uns, statische Seiten mit der Geschwindigkeit eines CDNs auszuliefern, ohne Server warten zu müssen.


6. SEO und Google Jobs Integration

Für Recruiting ist SEO überlebenswichtig. Da wir volle Kontrolle über das HTML haben, generieren wir für jede Detailseite dynamisch strukturierte Daten (JSON-LD).

Die Funktion processGoogleJobsStructuredData mappt unsere internen Job-Daten auf das offizielle Schema.org JobPosting Format. Das ermöglicht es Google, die Stellenanzeigen direkt in der "Google for Jobs" Suche anzuzeigen – ein massiver Mehrwert für unsere Kunden.

typescript
// populate.ts - SEO const structuredData = { "@context": "https://schema.org/", "@type": "JobPosting", "title": job.fullName, "description": job.description, // HTML Beschreibung "datePosted": new Date().toISOString(), "hiringOrganization": { "@type": "Organization", "name": data.company.name, "logo": IMG_BASE_URL + data.company.logo }, "jobLocation": { "@type": "Place", "address": { "@type": "PostalAddress", "addressLocality": job.location.city, "postalCode": job.location.zipCode } }, "baseSalary": { "@type": "MonetaryAmount", "currency": "EUR", "value": { "@type": "QuantitativeValue", "value": job.salary, "unitText": "MONTH" } } }; // Injection in den Head $("head").append(`<script type="application/ld+json">${JSON.stringify(structuredData)}</script>`);

Gleichzeitig setzen wir dynamisch OpenGraph Tags (og:image, og:title), damit Links, die auf LinkedIn oder WhatsApp geteilt werden, mit dem korrekten Vorschaubild und Titel erscheinen.


Fazit

Das Projekt bei Stahlhart Recruiting zeigt, wie mächtig die Kombination aus spezialisierten Tools ist. Wir haben nicht versucht, einen eigenen Website-Builder von Null zu schreiben – ein Vorhaben, das oft an der Komplexität eines WYSIWYG-Editors scheitert.

Stattdessen haben wir Webflow für das genutzt, was es am besten kann (Design), und React/Node.js für das, was sie am besten können (Datenstrukturierung und Logik). Google Cloud fungiert als unendlich skalierbare Infrastruktur im Hintergrund.

Das Ergebnis ist eine Plattform, die:

  1. Skalierbar ist: Ob 10 oder 10.000 Karriereseiten macht für die Architektur keinen Unterschied.
  2. Wartbar ist: Design-Änderungen werden im Webflow gemacht, exportiert und sind sofort für alle Kunden verfügbar.
  3. Performant ist: Statisches HTML im CDN schlägt jede serverseitig gerenderte WordPress-Seite.

Dieser Ansatz der "Decoupled Rendering Engine" ist ein Blueprint für Agenturen und SaaS-Plattformen, die massenhaft personalisierte Webseiten generieren müssen, ohne Kompromisse bei Qualität oder SEO einzugehen.

Über den Autor

Alex Friedl

Alex Friedl

Experte für digitale Produkte

Ich vereine User Research, Prototyping und TypeScript Frontend-/API-Entwicklung um exzellente digitale Lösungen zu gestalten.

Alexander Friedl
Checking...
Checking...

Lassen Sie uns über Ihr Projekt sprechen

Ich verwandle komplexe Anforderungen in elegante Lösungen.

UX Research
UI & IxD Design
App Entwicklung

+49 157 581 508 46