A full agency website built end-to-end — then a custom Gemini sales-offer generator wired into the same Payload stack.
Agency website + AI platform
BlueBee Marketing
2026
Co-founder · Full-Stack · AI
At a glance
The whole agency website — design, build, content model, PL+EN i18n — plus a custom Gemini sales-offer generator and a self-hosted Twenty CRM on the same Mikrus VPS.
01 — The problem
BlueBee needed a website first. Then they needed to sell faster than they could write.
The first half of this project was the public site. A new agency with an AI-first positioning needed a brand surface that looked like it — a hero that didn’t apologise, ten services laid out without the usual stock-photo grid, six industries with their own argument for why BlueBee fits, real case studies, a testimonials wall, a blog. Both languages. Translated URLs, not just translated copy. Editable by the agency without engineering touching anything.
The second half was the boring part of running an agency: sales. Every offer is bespoke. Industry-specific intro, three pricing tiers tuned to the client’s revenue band, the same five proof-points reshuffled. Writing one from scratch takes 45–90 minutes; the agency that writes faster wins the meeting. The naive answer is “ask Gemini to generate the offer”, and the naive answer breaks in five ways: it rewrites your sacred prices, silently switches Polish to English mid-document, drops half the bullet points, runs for 80+ seconds and nginx 504s before the response arrives. And the moment you start editing the generated HTML by hand, the next refine pass overwrites your edits.
So the project became two things on the same Mikrus VPS: a full agency website on Payload CMS v3 + Next.js 15, and a custom Gemini-powered offer-generator built into the same admin, with the four prompt-guard layers it took to stop hallucinating prices. Plus a self-hosted Twenty CRM on a sibling subdomain so client data never leaves the host. One stack, one VPS, one engineer, two products.
02 — The vision
Site is the brand, before it’s a funnel. — then the platform makes the agency faster than its competitors.
Four product principles drove every decision:
- Site is the brand, before it’s a funnel. Bold typography on neon yellow, no stock photos, a magenta hero character that’s memorable enough to come up in client onboarding calls. Ten services laid out as real services, not card-grid placeholders. The site has to look like the agency it’s selling before any AI gets involved.
- Editable by the agency, not by engineering. Every service, industry, case study, testimonial and blog post lives in Payload CMS as a first-class collection. PL + EN translated per field. URL paths translated through middleware. The agency runs the content; engineering ships features.
- The offer-generator never lies about prices. SACRED NUMBERS are extracted from the brief and regex-validated against the generated HTML; any drift fails the response and a stricter retry kicks in. Pricing is the agency’s contract with the client, not the LLM’s suggestion. Hand edits get
data-user-edited="1"and Gemini is contractually obligated to leave them alone on refine. - One VPS, no SaaS bills. Site, Payload admin, Gemini generator, Puppeteer PDF and self-hosted Twenty CRM all share one Mikrus VPS. The agency owns the data; nothing client-related leaves the host.
03 — Who it’s for
Four very different client types, one offer-generator that adapts the tone per industry.
E-commerce manager
Buys Google Ads + Meta Ads + monthly performance reports. The offer leads with creative testing cadence and CPA targets; pricing tier matches monthly ad spend.
Fitness brand founder
Wants TikTok / IG dominance + community + DTC strategy. The offer foregrounds short-form content velocity and creator workflows; case studies swap in fitness-aligned proof.
B2B accounting firm
LinkedIn presence + thought-leadership content + SEO. Offer copy goes corporate, services foreground long-form content and lead-gen funnels, the visual tone calms down.
Agency owner (white-label)
Resells the offer-generator + slide decks under their own brand. Custom Payload field flips the visible logo and contact block per render; Twenty CRM tracks the reseller’s pipeline separately.
04 — The architecture
One Next.js app, one Payload admin, one custom Gemini pipeline, one self-hosted CRM next door.
A single Next.js 15 build serves the public marketing landing (PL/EN with custom path rewriting) and the Payload CMS v3 admin panel. The custom /api/generate-offer route lives inside Payload’s Local API namespace, with an in-memory job queue, streaming Gemini responses through a polling endpoint to dodge nginx’s 60 s timeout. The same admin hosts the Puppeteer-driven PDF route that measures the iframe preview’s real width before launching the browser. PostgreSQL stores Payload collections + job state. Resend handles transactional email from form submissions. A separate Docker Compose stack runs self-hosted Twenty CRM on crm.bluebee.marketing, kept on the same Mikrus VPS but isolated as its own service.
/uslugi ↔ /en/services — hreflang · sitemap · redirect by Accept-Language/api/generate-offer · /status/[jobId] · /pdf — in-memory job queue, SSE-like pollingcrm.bluebee.marketing05 — Technical challenges
Six engineering problems that shaped the platform.
Async Gemini generator → 504 timeout
Problem. Synchronous Gemini calls took 80+ seconds for a full offer. Nginx’s default proxy timeout fires at 60 s; the salesperson saw a 504 and the half-generated content was lost. Retry only ate quota.
Solution. In-memory job queue in src/lib/offer-jobs.ts (commits f7ad1df, 78476c4). POST /api/generate-offer creates a job, returns jobId immediately; client polls /api/generate-offer/status/[jobId] every 1.5 s. generateContentStream updates elapsed seconds, bytes received, fragment count, shimmer bar progress, and exposes a Cancel button. GC after 15 minutes so the process doesn’t leak. Single PM2 fork tolerated because state lives in memory; if we grow past one fork, swap to Redis is a 20-line change.
Hallucinated prices and silent PL→EN drift
Problem. Gemini quietly invented prices the agency hadn’t agreed to, swapped Polish for English mid-document despite the prompt saying not to, and dropped half the bullet points when the prompt got long. Three failure modes that all looked like “maybe the model just doesn’t like this prompt today”.
Solution. Four prompt-guardrail layers in src/lib/gemini.ts (commits f7ad1df, 5deaaab). (1) Prompt rule #0 “SACRED NUMBERS NIE mogą się zmienić” + post-generation regex validation against the original brief; mismatched numbers fail the response and trigger a stricter retry. (2) Language hard lock: localStorage bb-offer-language + an explicit PL↔EN translation table in the prompt (“Pakiet” → “Package”, etc.). (3) Completeness check: extractBulletPoints() demands every bullet from the prompt appears in the output when there are ≥3. (4) data-user-edited="1" hard lock (see challenge 4). Temperature 0.35 for first draft, 0.2 for refine; maxOutputTokens 32K. Result: zero price hallucinations on 50+ shipped offers.
Puppeteer PDF that matches the iframe exactly
Problem. First PDF route used a hardcoded 1280 px Puppeteer viewport. The iframe preview in admin used the container’s real width (variable, depending on sidebar state). PDFs broke at different points than the preview; pagebreaks landed mid-table; salespeople stopped trusting the “download PDF” button.
Solution. Server-side route in src/app/(payload)/api/generate-offer/pdf/route.ts (commits cc19b83, 6edb917) measures the actual .page width from the HTML before launching the browser, sets the Puppeteer viewport dynamically, and renders with @page { margin: 0 } plus a 10 px buffer and pageRanges: '1' to enforce single-page A4. Offers.ts hook sanitises the domain before save so the same HTML re-renders identically next time. PDF and preview now match pixel-for-pixel.
WYSIWYG inline edit with a hard lock against refine
Problem. The salesperson wanted to tweak the generated HTML by hand inside the admin iframe. The next “refine with Gemini” pass overwrote everything. Adding the hand edits back after every refine was worse than not having the refine at all.
Solution. src/components/admin/inline-edit.ts (~250 lines, commit 5deaaab). Toggle “Edytuj ręcznie” sets contenteditable=true on text leaf nodes inside the iframe, shows a floating mini-toolbar (Bold / Italic / Underline / Link via execCommand). Each element snapshots its original text in data-bb-orig; on save the diff stamps data-user-edited="1" and applies an orange outline so the salesperson can see what’s locked. On refine, the prompt builder extracts every locked element and prepends rule #0 to the Gemini prompt: “HARD LOCK — never touch elements with data-user-edited”. UI shows a “🔒 N zablokowanych” counter above the iframe so the lock state is never invisible.
i18n middleware with translated EN paths
Problem. The site needed Polish without a prefix (/uslugi), English with both a locale prefix and translated paths (/en/services, not /en/uslugi). Payload CMS supports field-level localisation, but not URL rewriting. Next.js’s built-in i18n routing doesn’t translate path segments either.
Solution. Custom middleware in src/middleware.ts. Accept-Language detection redirects EN-preferring users to /en/ on root visits (but not on direct links, to respect the share-and-pin pattern). Path rewriting through a static map: /en/uslugi → /en/services, /en/branze → /en/industries, etc. Payload seed scripts populate both locales on every content collection. UI dictionaries in src/lib/dictionaries/{pl,en}.ts. Layout emits hreflang tags and a per-locale sitemap. Both URLs index in Google with the correct language; canonical resolution stays clean.
AOS scroll animations vs SSR opacity trap
Problem. The WebGen template baked AOS scroll animations into every section via data-aos attributes. AOS’s CSS sets opacity: 0 on those elements until a scroll event fires the JS. In Next.js SSR there is no scroll event on the server, so first-paint shipped invisible content; users saw a blank landing for a beat before hydration caught up.
Solution. Override in public/css/aos-override.css: [data-aos] { opacity: 1 !important; transform: none !important; }. Animations are sacrificed; instant content beats clever entrance every time, especially when the entrance is a flicker. AOS JS still loads (deferred, after content) so existing in-page interactions that depend on its presence don’t break. Simple, ugly, correct.
06 — The workflow
Brief in admin. Stream in iframe. Lock the parts that matter. Ship the PDF.
The salesperson writes a brief inside Payload admin, picks persona + industry + pricing tier + language, hits “Generate offer”. Backend creates a job, returns jobId within 50 ms, frontend shows streaming progress (elapsed seconds, bytes, fragments, shimmer bar, Cancel). Gemini Flash runs through generateContentStream with the four guard layers active. The HTML lands in the admin iframe live as it streams. Salesperson toggles “Edytuj ręcznie”, fixes copy per element (each stamped data-user-edited="1"), hits Save — the Offers collection in Payload commits with the lock state preserved. “Download PDF” reads the iframe’s actual .page width, launches Puppeteer at that exact viewport, returns a single-page A4 that matches the preview pixel-for-pixel.
07 — Feature highlights
Ten services across six industries on the landing, two load-bearing platforms behind the admin login.
The marketing surface advertises Google Ads, Meta Ads, TikTok Ads, Social Media, SEO & Content, Web, Branding, Video, Email Marketing and Audits — across e-commerce, fashion, zoo, fitness, real estate and offices. The two features below are the infrastructure that lets two people deliver all of that.
+ Self-hosted Twenty CRM
Docker Compose stack (server + worker + Postgres + Redis) on crm.bluebee.marketing. Custom fields aligned with the agency’s pipeline stages (brief → offer sent → contract → onboarded → retainer). Zero per-seat cost as the team grows; own data, own backup schedule, single SSH login away from the rest of the stack.
+ Payload form-builder + Resend
Contact and brief forms are first-class Payload collections, configured through the admin without code changes. Submissions route through Resend for transactional delivery (with retry + audit log) and land in the same Postgres instance as the rest of the content. Per-industry form variants pick up the right routing and the right autoresponder copy.
08 — Stack
Picked for two-person agency velocity on one Mikrus VPS.
Every dependency earns its place by either replacing a SaaS bill or letting one engineer ship what would take a team. The whole platform fits next to the Twenty CRM and a Postgres instance on a single host.
(frontend) and (payload)./api/generate-offer route share the admin’s auth and access control without a separate server.@payloadcms/db-postgres@payloadcms/richtext-lexical@google/genai v1.47generateContentStream. Pricing math works at the offer generation cadence we ship./en/ + translated path segments. next-intl didn’t support our path-rewriting story cheaply.main.css (Funnel Display + Mozilla Text)--theme: #E3FF04. Time-to-ship beat a full Tailwind rewrite.aos-override.css patches the SSR opacity trap (challenge 6).09 — Results
Live, used daily, ten services across six industries.
BlueBee is in production on bluebee.marketing with the offer generator running inside Payload admin, the self-hosted Twenty CRM on crm.bluebee.marketing, and the PL+EN site serving clients across e-commerce, fashion, zoo, fitness, real estate and offices. From 999 zł/msc entry packages, the same generator drafts both an SMB intro and an enterprise retainer offer with the same guard rails.
10 — Engineering decisions
Five ADRs — the load-bearing “why we did it this way”.
Each decision below ruled out a more popular alternative on purpose. The popular ones cost more, took longer, or didn’t fit the “one Mikrus VPS, two engineers, ten services” constraint.
Payload CMS v3 over Strapi or Sanity
Context. We needed a CMS that could host both the marketing content and a custom admin surface for the offer generator. Strapi’s plugin model gets clunky once you go off-rail; Sanity is great for content but hostile to custom backend code colocated with the schema.
Decision. Payload CMS v3 — TypeScript-native, code-as-config, Postgres adapter. Custom React components mount in the admin alongside Lexical rich text. The Local API exposes the same access-controlled queries to our custom route handlers, so the offer generator inherits admin auth for free.
Consequence. One Next.js process, one admin, one Postgres. No CMS-vs-app boundary to maintain. The WYSIWYG inline-edit overlay (challenge 4) drops directly into the Payload iframe without a permissions plugin.
Self-hosted Twenty CRM over HubSpot or Pipedrive
Context. Two-person agency growing fast. HubSpot starts at ~$100/seat and scales linearly; Pipedrive cheaper but locks data behind their API. Both put client conversations on someone else’s server.
Decision. Self-hosted Twenty CRM in Docker Compose on crm.bluebee.marketing — server + worker + Postgres + Redis. Custom fields aligned to the agency’s actual pipeline (brief → offer sent → contract → onboarded → retainer). Same VPS, separate service.
Consequence. Zero per-seat cost as the team grows; backups are pg_dump; client data never leaves the host. Trade-off accepted: we maintain the upgrade path ourselves, and we lose HubSpot’s integration ecosystem — neither bothers us at our scale.
In-memory job queue over Redis
Context. The Gemini generator runs as a background job with progress streaming. The textbook answer is Redis + BullMQ. We’d already have Redis on the box for Twenty CRM — tempting to reuse.
Decision. Keep it in-memory inside the single PM2 fork (src/lib/offer-jobs.ts). One Map, one polling endpoint, GC after 15 minutes. Job state lives where the request was handled; no cross-process serialisation.
Consequence. Fewer moving parts. Throughput today (5–20 offers/day) is comfortably within one fork. If we ever need horizontal scale or restart resilience, swapping in BullMQ is <50 lines — the rest of the code already treats jobs as opaque handles.
Prompt engineering over Gemini structured outputs
Context. Gemini’s structured-output feature would give us typed JSON for free. But our problem isn’t “parse the response” — it’s “don’t change the prices, don’t switch language, don’t drop bullets, don’t touch hand-edited DOM”. Structured outputs solve the wrong half.
Decision. Plain HTML responses with four prompt-engineering guardrails (see challenge 2): SACRED NUMBERS regex validation, language hard lock, completeness check via extractBulletPoints(), and the data-user-edited hard lock. Each rule is independently testable and triggers a stricter retry when it fails.
Consequence. Full control over model behaviour, independent of SDK feature evolution. The guards are layered — any one of them firing is a clear signal of which constraint was violated, which speeds debugging when a new prompt template misbehaves.
Bootstrap 5 retained from WebGen template
Context. The original WebGen template ships in Bootstrap 5. The trendy choice would be a full Tailwind rewrite — cleaner utilities, better tree-shaking, modern conventions. The cost is two weeks of carefully recreating every component the design team already approved.
Decision. Keep Bootstrap 5. Override --theme: #E3FF04 and --theme2: #FF7425 in public/css/main.css. Add minimal custom utilities where the grid falls short. The Payload admin uses its own React component library, untouched.
Consequence. Time-to-market beat purity. The site shipped, started winning clients, and the budget for a Tailwind rewrite went into the offer generator instead. The brand tokens cover the look; nobody outside engineering knows or cares which framework draws the buttons.
11 — Production scaling story
Four phases from a WebGen template to an AI-first agency platform.
BlueBee shipped a static brochure first, learned what content the agency actually wanted to manage, then earned the right to bolt on each piece of platform infrastructure as it became load-bearing.
Phase 1 — WebGen template + static deploy
Bootstrap 5 template, hardcoded content, single static build on the Mikrus VPS. Brand tokens swapped to #E3FF04 + #FF7425; nav, hero and services laid out by hand. Goal was to validate the design with the agency’s actual clients before investing in a CMS — if they hated the look, no point wiring up Payload.
Phase 2 — Payload CMS v3 + Postgres + PL/EN i18n
Migrated every section into Payload collections (services, industries, case studies, blog posts, team). Built the custom locale middleware so PL stays at /uslugi and EN gets /en/services with hreflang + per-locale sitemap. The agency took over content updates; engineering stopped touching copy.
Phase 3 — Gemini offer generator (sync → async, then guards)
First version was a synchronous Gemini call inside the admin. Salespeople saw 504s within a week. Refactor: in-memory job queue + generateContentStream + polling UI with Cancel. Then the hallucination story landed — sacred-number drift, language switching, dropped bullets — and the four-layer guard system grew over the next sprint. Hand edits started overwriting each other; the data-user-edited hard lock closed that loop.
Phase 4 — Twenty CRM + Puppeteer PDF 1:1
Self-hosted Twenty CRM in Docker Compose next to the main app on the same VPS, separate subdomain, separate Postgres. Custom fields modelled the actual agency pipeline. PDF route rewrote to measure the iframe’s real width before launching Puppeteer, so the downloaded offer matches the preview pixel-for-pixel. The salespeople stopped complaining about “the PDF looks different” and started shipping offers in 25 seconds.
Like what you see?
Let’s build the next one.
From a blank page to a working product — AI, automation, full-stack engineering. Get in touch and let’s talk about your idea.