Product · AI · Analytics
Year: 2026Role: Product design, development, AI and analytics
A unique CV link and a portfolio tailored to every company. AI orders the projects for the recruiter, and PostHog shows what worked.
Scroll to see how it works01 Base CV — the same file for everyone
- a separate PDF and link per company
- 1 : 1
- project order per company
- AI
- metrics in the statistics panel
- 7
- data paths into PostHog
- 3
01 · The solution
Every company gets its own CV and portfolio.
Every CV carries a unique link to a portfolio where AI orders the projects for that employer. After sending it, I can see whether anyone opened it and which projects drew attention.
- CV
- A separate file with a unique link for every company
- Portfolio
- Project order set by AI for the company
- Insight
- Opens, portfolio visits and the projects that draw attention
The problem
The same CV and portfolio for a healthcare company, a shop and a bank — then silence after sending.
- 1CV version for every company
- 1project order for everyone
- 0signals whether anyone opened the CV
02 · The whole flow
From a form to measurement in six steps.
Who does what at each stage — me in the panel, the system on the server and the recruiter on the other side.
I fill in a form in the panel
Company, contact, CV language (PL/EN) and the company website. The name fills itself in from the website, and the panel warns me if my CV may already have gone to that employer.
New link03 · The panel
A link pair in seconds — with live progress.
The panel is for me only: LinkedIn sign-in plus a passkey. The server doesn't answer with one JSON at the end but with an NDJSON stream — the panel reads it as it arrives and shows which step is running.
The AI ranking is best effort: if it fails, the link is still created with the default order. While I'm still typing, the panel checks whether my CV may already have gone to this company — in links I generate by hand and in batch sends.
- 8steps reported by the server
- 500 msafter the last keystroke — duplicate check
- 1 UUIDper …cv and …pf token pair
New link
CV link for “Przykładowa Klinika Sp. z o.o.” — created 21 Aug 2026, CV opens: 2
Matched on company name, e-mail or domainDone
jakubnowak.eu/go/cv/3f9c2a7e…cvjakubnowak.eu/go/p/3f9c2a7e…pf- 1 Healthcare
- 2 Patient app
- 3 Medical data
The real panel
The admin panel's interface is in Polish.
The new-link form
Fictional data// Stream NDJSON progress events so the admin UI can render a live progress bar
// and show exactly which step is running underneath (CMS fetch → company-site
// analysis → AI ranking → DB write). One JSON object per line.
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: Record<string, unknown>) =>
controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));
try {
send({ type: "progress", progress: 12, message: "Walidacja danych odbiorcy…" });
04 · The PDF
One file, a unique link.
Every PDF is generated on request from the base CV. The server swaps the target of the jakubnowak.eu link for this one company's address — the CV looks and reads exactly the same.
- linkedin.com/in/… unchangedhttps://linkedin.com/in/…
- jakubnowak.eu rewrittenhttps://www.jakubnowak.eu/go/p/3f9c…pf
Every in-browser open reaches the server (Cache-Control: private, no-store), so I know whether and when the CV was opened. Downloading the PDF from the panel to attach it doesn't inflate the counter.
- jakubnowak.eu and subdomains
→ /go/p/<token> - LinkedIn and other domains
unchanged - Already tracked links (/go/…)
unchanged
- Base PDFs outside the public folder
- Read once per function instance
- Personalised in memory
- Error → the static Polish CV
for (const page of pdfDoc.getPages()) {
const annots = page.node.lookupMaybe(PDFName.of("Annots"), PDFArray);
if (!annots) continue;
for (let i = 0; i < annots.size(); i++) {
const annot = annots.lookupMaybe(i, PDFDict);
if (!annot) continue;
if (annot.get(PDFName.of("Subtype")) !== PDFName.of("Link")) continue;
const action = annot.lookupMaybe(PDFName.of("A"), PDFDict);
if (!action) continue;
const uri = decodeUri(action.lookup(PDFName.of("URI")));
if (!uri || !isPortfolioUri(uri)) continue;
action.set(PDFName.of("URI"), PDFString.of(trackedUrl));
}
}
05 · AI ranking
What matters to this company comes first.
The ranking is created once, when the link is generated. The model gets the company website's text and the project catalogue, and returns a complete order of every id — weighing the industry, business model, sales channels and likely needs.
Pick a company and watch the grid change. The companies and orders in this scene are examples.
“Modern medical care — book a visit online, test results in the patient app…”
- Website text
- plain text, up to 4,000 characters, 6 s timeout
- Catalogue (JSON)
- id, title, subtitle, category, client, year
- Output
- a JSON array, each id exactly once
- 01Patient appHealthcare
- 02Medical dataHealthcare
- 03Headless shopE-commerce
- 04Bank onboardingFintech
- 05Loyalty programmeCRM
- 06Brand identityBranding
- 07Mobile appMobile
- 08Social campaignCampaign
I don't take the model's word for it
The answer is validated: unknown and repeated ids are dropped, and projects the model left out go to the end. The stored order always covers the whole catalogue, and the function never throws.
- health-app
- med-data
- made-up
- mobile
- health-app
- loyalty
- known
- unknown — dropped
- repeated — dropped
- left out — appended
const seen = new Set<string>();
const ordered: string[] = [];
for (const item of parsed) {
if (typeof item === "string" && known.has(item) && !seen.has(item)) {
seen.add(item);
ordered.push(item);
}
}
if (ordered.length === 0) return [];
// Append any ids the model omitted, preserving the catalog order, so the
// stored order always covers every project.
for (const id of allIds) {
if (!seen.has(id)) ordered.push(id);
}
return ordered;
06 · The click
One click, four things at once.
The link in the PDF leads to /go/p/<token>. The server records the visit, sends an event to PostHog, sets up to two cookies valid for 10 minutes and redirects to the homepage with an rid parameter and UTM tags.
- Recruiterclicks the link in the PDF
- /go/p/<token>serverless function
- Postgresvisit recorded
- PostHogoutreach_link_visited
- Homepage?rid · utm_source=outreach
An analytics failure doesn't break the redirect — every step has its own error handling. The homepage reads the cookie only after it mounts in the browser, so the server HTML is the same for everyone.
Who wins when the grid is sorted
- 01AI order from the CV linkthe cv_order cookie (10 min)
- 02The featured projectthe featured field in Sanity
- 03Popularity over 30 daysa HogQL view in PostHog
- 04CMS orderon ties and with no data
// Outreach override: when an AI-ranked order is present (CV link recipient),
// lead with those projects in that exact order, then the rest in their
// current order. Wins over popularity/featured/industry sorting.
const finalProjects =
projectOrderIds && projectOrderIds.length > 0
? (() => {
const rank = new Map(projectOrderIds.map((id, idx) => [id, idx]));
const ranked = popularitySorted
.filter((p) => rank.has(p.id))
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
const rest = popularitySorted.filter((p) => !rank.has(p.id));
return [...ranked, ...rest];
})()
: popularitySorted;
07 · PostHog
PostHog as the analytics hub — not just charts.
The database panel answers whether a link worked. PostHog (EU region) answers what works on the site — and some of the data flows back into the product.
Hover a source or a feature to see what connects to what.
Where the data comes from
What it powers
08 · Collecting data
Events that can't get lost.
The PostHog script and events go through a reverse proxy on my own domain, and initialisation waits for the first interaction or 8 seconds — an eager start was one of the causes of the mobile PageSpeed score dropping to 35/100.
When PostHog starts
For visits from the CV link (the rid parameter) PostHog starts right away, so a short visit isn't lost. If an interaction comes before the script has loaded, initialisation waits for it instead of being lost.
- The script loads after the load event
- Waiting for an interaction
- Interaction
- PostHog is running
Scrolling deliberately doesn't start it — Lighthouse scrolls the page too.
Server: a race against the freeze
The /go endpoints run as serverless functions, and the instance can be frozen right after the response. The buffered capture() kept losing outreach_link_visited. captureImmediate resolves only once the event is delivered, and a flush in finally runs even after an error.
capture()captureImmediate()Groups: link and company, this session only
Every link is an outreach_link group, every company a company group. On a later visit without the rid parameter, the code compares the session id and clears the groups if it changed — my own tests once ended up credited to the link's company.
export async function captureServerEvent(
distinctId: string,
event: string,
properties?: Record<string, any>,
groups?: Record<string, string>
): Promise<void> {
const client = getPostHogClient();
await client.captureImmediate({
distinctId,
event,
properties,
...(groups ? { groups } : {}),
});
}
09 · Event catalogue
Custom events next to autocapture.
PostHog captures pageviews, page leaves, autocapture, heatmaps and unhandled exceptions automatically. Custom events describe the rest — in the special projects area PostHog doesn't run in the browser.
outreach_/go/cv and /go/plink_ visited link_type, outreach_tokenServer$groupidentifyserverlink and company group namesServerservice_the whole appdegraded component, reason, severityServerportfolio_landing with ridlanded outreach_tokenBrowserwork_case study pageproject_ viewed project_slug, project_categoryBrowsercontact_contact formform_ submitted message_length, sourceBrowsercopilot_Copilotmessage_ sent message_length, word_countCopilotcopilot_Copilotresponse_ received response_length, response_time_msCopilotcopilot_Copilotmessage_ feedback helpfulCopilot
10 · Popularity
PostHog orders the project grid.
The source is a single event, work_project_viewed — direct landings included, because the event waits in a queue until PostHog has started. Two saved HogQL views, created over the API, count views over 30 days.
It's a feedback loop: popular projects sit higher, so they get viewed more — which is why the order from the CV link takes priority over popularity.
- Case study openedwork_project_vieweddata in PostHog
- v_clean_project_views90 days, unified slugsdata in PostHog
- v_popular_projects_30dmaterialised hourlydata in PostHog
- /api/posthog-insightsLIMIT 10 · cache 300 sdata in the app
- usePosthogInsightlocalStorage · 1 hdata in the app
- WorkGriddescending, tie → CMSdata in the app
How long each layer keeps the data (minutes)
- View materialisation60 minevery hour
- Next.js data cache5 minrevalidate: 300
- CDN Vercel15 mins-maxage 300 + SWR 600
- Browser localStorage60 min1 h TTL
The layers add up (about 2 h 20 min) — a deliberate trade-off between freshness and speed and query cost.
The “🔥 popular today” badge
The live-activity endpoint reads raw events from the last 24 hours. The grid polls it every 5 minutes and marks the most-viewed project. The pulse changes opacity only and switches off under prefers-reduced-motion.
- Healthcare popular today
- E-commerce
- Fintech
- Branding
{
name: 'v_popular_projects_30d',
materialize: true,
syncFrequency: 'every_hour',
description: 'Aggregated project click counts (last 30 days, deduplicated slugs)',
query: `SELECT
project_slug AS project_slug,
count() AS clicks
FROM v_clean_project_views
WHERE timestamp > now() - INTERVAL 30 DAY
GROUP BY project_slug`,
},
11 · Copilot
The prompt in PostHog, telemetry without the text.
The on-site AI assistant (Gemini 2.5 Flash) loads its system prompt from PostHog's prompt management — I can change it without a deploy. Model calls are traced with OpenTelemetry.
Spans carry the model, token usage and duration — without the prompt or response text and without tying them to a person. After every response the span buffer is flushed in after(), so spans aren't lost when the function freezes.
Where Copilot gets its prompt
One call's trace
- gemini-2.5-flash
- inputTokens
- outputTokens
- functionId: copilot-chat
prompt textresponse textperson
after(flushPostHogOtel)
experimental_telemetry: {
isEnabled: true,
functionId: "copilot-chat",
// Spans keep the model, token usage, latency and the metadata below,
// not the text: the system prompt carries the visitor context and the
// messages are the visitor's own words — and spans aren't tied to a
// person.
recordInputs: false,
recordOutputs: false,
12 · Monitoring
I know about an outage before a visitor does.
Wherever the app falls back or an operation silently fails, a single function reports service_degraded with the component, reason and severity — under one system id, in parallel with an exception for high-severity errors.
service_degradedcopilot.systemPromptprompt_load_failedmediumcms.projectssanity_fetch_failedhigh / mediumcms.blogsanity_fetch_failedmediumemail.sendresend_send_failedhighcontact.ownerEmailowner_email_failedmediumcron.generateBlogPostcron_blog_failedmedium
const distinctId = d.distinctId ?? SYSTEM_DISTINCT_ID;
const properties = {
component: d.component,
reason: d.reason,
severity: d.severity,
error_message: errorMessage(d.error),
...d.meta,
};
// High-severity errors also surface in PostHog Error Tracking with a stack.
// Both are sent at once and awaited, so a frozen instance can't drop them.
await Promise.allSettled([
d.severity === "high" && d.error instanceof Error
? captureServerException(d.error, distinctId, {
component: d.component,
reason: d.reason,
...d.meta,
})
: undefined,
captureServerEvent(distinctId, "service_degraded", properties),
]);
13 · Technology
Built, measured, improved.
One Next.js 16 app on Vercel. A token pair from one random UUID carries no company data, and the panel is protected by LinkedIn sign-in and a passkey.
- App
- Next.js 16 · App Router · streaming
- Database
- Vercel Postgres (Neon) · Drizzle ORM
- Content
- Sanity
- AI
- Vercel AI SDK · Gemini 2.5 Flash · AI Gateway
- pdf-lib
- Analytics
- PostHog EU · posthog-node · OpenTelemetry
- Panel access
- LinkedIn (NextAuth) + passkey
- 1 : 1a separate PDF and link for every company
- AIproject order matched to the company website
- 7metrics in the statistics panel
- 3data paths into PostHog
Let's talk
kontakt@jakubnowak.eu