DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The free plan searches exactly like the paid one and hides the results, and the blurred rows are fake on purpose

Nakodo (nakodo.app) searches YouTube, Instagram and TikTok for creators that fit a brand's brief, then emails the ones that fit. The Free plan shows the best 20 of them and hides the rest until you upgrade. You can see that line in the comparison table on nakodo.app/pricing: creators shown per campaign, best 20 against all.

The first implementation of that limit was wrong in an interesting way, and fixing it turned a product decision into one SQL expression and one deliberately dishonest-looking React component.

Version one capped the work

Originally a Free campaign stopped searching once it had 20 good fits. That is the obvious reading of "shows 20": do 20 creators' worth of work.

It produces a bad product and a bad upgrade. The campaign's progress bar stops with keywords unsearched, so the user cannot tell whether their brief was any good or whether the search just ran out of permission. If they do upgrade, the answer to "what was I missing" is "nothing yet, we never looked", so the upgrade is a promise rather than a reveal. And the search was never the expensive part per user anyway: creator data is reused across campaigns within a freshness window, so finding a creator once pays off more than once.

Version two inverts it. Free searches exactly like a paid plan, the brief is fully explored, and the limit moves to the view: the best 20 useful creators are shown, the rest are counted and locked. The commit message was "Show Free the best 20 creators and blur the rest, instead of stopping the search", which is as close as a commit log gets to a product principle.

"Shown" is a SQL predicate, not application logic

The limit has to hold in every query that touches results: the list, the counts, the funnel, the export, the outreach picker. So it is written once, as SQL over an alias, and composed:

export const fitCondition = (alias: string) =>
  `${alias}.stage <> 'rejected' and ${alias}.score >= ${FIT_MIN_SCORE} and not ('not_a_creator' = any(${alias}.flags))`;

export const usefulCondition = (alias: string) =>
  `${fitCondition(alias)} and exists (select 1 from channel_contacts e where e.channel_id = ${alias}.channel_id and e.kind = 'email')`;
Enter fullscreen mode Exit fullscreen mode

Two vocabulary words, which is most of the work of making this tractable. A fit is a result that scored well enough and is a creator rather than a brand account or a compilation channel. A useful result is a fit with a published email, meaning someone Nakodo can actually write to. The cap counts useful results, because a limit of 20 creators you cannot contact would be a limit on nothing.

Rejected rows, which are the accounts that turned out to be private, empty or gone, are never shown on any plan. Their rows stay in the table so the same account is not checked again next week, which is a small thing that saves a surprising amount of outside API traffic.

Then the cap itself:

export function shownSql(alias: string, cap: number | null, scope: SQL): SQL {
  const col = (name: string) => sql.raw(`${alias}.${name}`);
  if (cap === null) return sql`${col("stage")} <> 'rejected'`;
  const n = sql.raw(String(Math.floor(cap)));
  const useful = sql.raw(usefulCondition("u"));
  return sql`(${col("stage")} <> 'rejected' and (
    exists (select 1 from outreach_threads t where t.campaign_id = ${col("campaign_id")} and t.channel_id = ${col("channel_id")})
    or (${col("campaign_id")}, ${col("channel_id")}) in (
      select r.campaign_id, r.channel_id from (
        select u.campaign_id, u.channel_id,
          row_number() over (partition by u.campaign_id order by u.score desc, u.created_at, u.channel_id) as place
        from campaign_channels u where ${scope} and ${useful}
      ) r where r.place <= ${n}
    )
    or (${col("stage")} in ('discovered', 'enriched', 'analyzed') and ${col("campaign_id")} not in (
      select u.campaign_id from campaign_channels u where ${scope} and ${useful} group by u.campaign_id having count(*) >= ${n}
    ))
  ))`;
}
Enter fullscreen mode Exit fullscreen mode

Three OR branches, and each one is a rule somebody would otherwise have filed as a bug.

A creator we already wrote to is always visible. The exists against outreach threads comes first for a reason: a conversation the user is part of cannot stop being visible because a better scoring creator arrived afterwards and pushed it out of the top 20. Hiding an open conversation would be indefensible, and without this branch it would happen silently, days later.

The top N by score, with a total ordering. row_number() over (partition by campaign_id order by score desc, created_at, channel_id) ranks the useful rows and takes the first N. The tie-breakers matter more than they look: score desc alone is not a total order, and in Postgres an unstable ordering means the set of rows under place <= 20 can legitimately differ between two identical queries. With created_at and then the id appended, the twentieth row is the same row on every page load, on the list and in the count.

Rows still being checked, but only while the campaign is short of N. A brand new Free campaign has nothing scored yet. Without the third branch its results page would be empty for several minutes while work is visibly running, which reads as broken. With it, in-flight rows show until the campaign actually has its 20 useful ones, and then they stop appearing. The not in (... having count(*) >= n) is the "still short" test.

cap === null short circuits the whole thing to "not rejected", so paid plans never pay for the window function.

The same predicate, negated, is what counts the locked rows for the upgrade note:

locked: sql<number>`count(*) filter (where ${locked})::int`,
Enter fullscreen mode Exit fullscreen mode

so "187 more creators found" and the list are two readings of one definition rather than two numbers that can drift.

The blurred rows are not real, and that is the feature

Now the teaser. The pattern everyone has seen is a list of real rows with filter: blur(5px) and an upgrade button over them. It is also, for anything you actually meant to withhold, a data leak: the rows are in the DOM, so deleting one CSS class in devtools is the paywall bypass.

Our locked creators are not in the response at all. What is behind the upgrade note is five hand written rows:

// Made-up rows drawn behind the upgrade note. The hidden creators never reach
// the browser, so there is nothing real to unblur.
const STAND_INS = [
  { title: "Hidden creator", handle: "@hidden.creator", score: 84, audience: "48.2K", views: "12.4K" },
  { title: "A creator Pro shows", handle: "@shown.on.pro", score: 79, audience: "126K", views: "31K" },
  { title: "Hidden", handle: "@hidden", score: 73, audience: "9.8K", views: "2.1K" },
  { title: "Another hidden creator", handle: "@another.hidden", score: 66, audience: "212K", views: "40.5K" },
  { title: "Creator name", handle: "@creator.name", score: 61, audience: "31.7K", views: "6.9K" },
];
Enter fullscreen mode Exit fullscreen mode

The names are written so that reading them undermines nothing. Anyone who does remove the blur finds "Hidden creator" and "@shown.on.pro", not a plausible fake creator that someone could screenshot as if it were a real search result. Fake data that could be mistaken for real data is a worse idea than either real data or obviously fake data.

The container does the rest:

<ul aria-hidden="true" inert className="pointer-events-none divide-y blur-[5px] select-none">
Enter fullscreen mode Exit fullscreen mode

aria-hidden plus inert keeps a decorative teaser out of the accessibility tree and out of tab order, so a screen reader user is not read five rows of the word "hidden" and a keyboard user cannot land on them. pointer-events-none and select-none stop the hover and text selection affordances that would suggest the rows are interactive. The real message sits in a normal, focusable element absolutely positioned over the stack, and the section has an aria-label that says what it is: creators hidden on your plan.

The copy in that note is generated from the plan, not hardcoded: how many more creators were found, how many of them are fits, what the current plan shows, and what the next plan adds, which for Free includes YouTube as a platform rather than just more rows.

Go and look

The Free plan needs no card, so the honest demo is to start a campaign and watch a search run to completion while the results page shows twenty of them. nakodo.app/pricing is the row this post is about, and the per-niche pages, for example YouTube gaming creators or TikTok food creators, are the public version of what a search covers.

Related posts from the same codebase: the job queue that does the searching, the retention rules that decide how long a creator's data may be reused, and what happens when a plan's outreach allowance runs out.

Top comments (0)