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 works
Example
Jakub Nowak — CVPDF · 4 pages

01 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.

Me

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 link

03 · 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
jakubnowak.euFictional data

New link

CompanyPrzykładowa Klinika Sp. z o.o.
ContactAnna Przykładowa
Company website (URL)https://klinika.example
CV languagePolish (PL)
Your CV may already have gone to this employer

CV link for “Przykładowa Klinika Sp. z o.o.” — created 21 Aug 2026, CV opens: 2

Matched on company name, e-mail or domain
Personalise the project order with AI
Gotowe ✓100%

Done

CV linkjakubnowak.eu/go/cv/3f9c2a7e…cv
Portfolio linkjakubnowak.eu/go/p/3f9c2a7e…pf
Top of the AI order
  1. 1 Healthcare
  2. 2 Patient app
  3. 3 Medical data
src/app/api/crm/cv-links/route.tsTypeScript
// 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…" });
The start of the progress stream

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.

Jakub NowakCV · 4 pages · PL or EN
Example

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 domainsunchanged
  • Already tracked links (/go/…)unchanged
  • Base PDFs outside the public folder
  • Read once per function instance
  • Personalised in memory
  • Error → the static Polish CV
src/lib/cvPdf.tsTypeScript
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));
  }
}
Rewriting the link annotations on every PDF page

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
Example
  1. 01Patient appHealthcare
  2. 02Medical dataHealthcare
  3. 03Headless shopE-commerce
  4. 04Bank onboardingFintech
  5. 05Loyalty programmeCRM
  6. 06Brand identityBranding
  7. 07Mobile appMobile
  8. 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
src/lib/projectRanking.tsTypeScript
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;
Validating the model's answer

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.

Example
  1. Recruiterclicks the link in the PDF
  2. /go/p/<token>serverless function
  3. Postgresvisit recorded
  4. PostHogoutreach_link_visited
  5. 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

  1. 01AI order from the CV linkthe cv_order cookie (10 min)
  2. 02The featured projectthe featured field in Sanity
  3. 03Popularity over 30 daysa HogQL view in PostHog
  4. 04CMS orderon ties and with no data
src/components/work/WorkGrid.tsxTSX
// 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;
The link's order layered over the popularity sort

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

PostHog (EU)

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.

  1. The script loads after the load event
  2. Waiting for an interaction
  3. Interaction
  4. PostHog is running
8.0or 8 s

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()
response❄ freeze
captureImmediate()
response❄ freeze

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.

Session from the link (rid)
outreach_linkcompany
Next session
outreach_linkcompany∅ groups cleared
src/lib/posthogNode.tsTypeScript
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 } : {}),
  });
}
Sending an event from the server

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.

9
  • outreach_link_visited/go/cv and /go/plink_type, outreach_tokenServer
  • $groupidentifyserverlink and company group namesServer
  • service_degradedthe whole appcomponent, reason, severityServer
  • portfolio_landedlanding with ridoutreach_tokenBrowser
  • work_project_viewedcase study pageproject_slug, project_categoryBrowser
  • contact_form_submittedcontact formmessage_length, sourceBrowser
  • copilot_message_sentCopilotmessage_length, word_countCopilot
  • copilot_response_receivedCopilotresponse_length, response_time_msCopilot
  • copilot_message_feedbackCopilothelpfulCopilot

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.

  1. Case study openedwork_project_vieweddata in PostHog
  2. v_clean_project_views90 days, unified slugsdata in PostHog
  3. v_popular_projects_30dmaterialised hourlydata in PostHog
  4. /api/posthog-insightsLIMIT 10 · cache 300 sdata in the app
  5. usePosthogInsightlocalStorage · 1 hdata in the app
  6. WorkGriddescending, tie → CMSdata in the app
popular sits higher → viewed more

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
scripts/setup-posthog-views.mjsJavaScript
{
  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`,
},
The materialised popularity view in the PostHog warehouse

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

CopilotPostHog
in-memory cache: 5 min

One call's trace

ai.generateText
ai.generateText.doGenerate
  • gemini-2.5-flash
  • inputTokens
  • outputTokens
  • functionId: copilot-chat
  • prompt text
  • response text
  • person

after(flushPostHogOtel)

src/app/api/copilot/route.tsTypeScript
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,
Telemetry in the generateText call, without prompt or response text (excerpt)

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_degraded
  • copilot.systemPromptprompt_load_failedmedium
  • cms.projectssanity_fetch_failedhigh / medium
  • cms.blogsanity_fetch_failedmedium
  • email.sendresend_send_failedhigh
  • contact.ownerEmailowner_email_failedmedium
  • cron.generateBlogPostcron_blog_failedmedium
src/lib/observability.tsTypeScript
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),
]);
The event and the exception in one place

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
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