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:
// 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:
{
"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:
// 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:
// 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:
// 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:
// 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:
- Sie sucht Container mit
-templateSuffix imdata-sh-id. - Sie extrahiert das innere HTML als Blaupause.
- Sie iteriert über das Array aus der Datenbank.
- Für jeden Eintrag wird ein Klon erstellt, befüllt und angehängt.
- Das Original-Template wird aus dem DOM entfernt.
// 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.
// 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.
// 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
initMapFunktion 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:
- Trigger: Der User klickt "Publish".
- API: Die API validiert den Request und erstellt sofort einen Task.
- 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):
// 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:
// 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.
- Der Load Balancer empfängt den Traffic (HTTPS).
- Ein SSL-Zertifikatsmanager verwaltet automatisch Zertifikate für hunderte von Domains.
- 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.
// 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:
- Skalierbar ist: Ob 10 oder 10.000 Karriereseiten macht für die Architektur keinen Unterschied.
- Wartbar ist: Design-Änderungen werden im Webflow gemacht, exportiert und sind sofort für alle Kunden verfügbar.
- 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.






