Is this you? As a journalist, you can create a free Muck Rack account to customize your profile, list your contact preferences, and upload a portfolio of your best work.
Claim your profile
Articles by Daniel Pertu
Eighteen useState calls in one help panel, and the key prop that throws them all away
The help button at the bottom left of every signed-in page in Nakodo opens a panel that does three jobs in one surface: it walks the written manual, it offers an assistant when the manual did not have it, and it opens a conversation with a person when that did not either. src/components/support/panel.tsx is 818 lines, the fourth largest client component in the app (the results table, the proposal editor and the brief editor are bigger; its own code comment claiming second place is out of date).
Four form components hold every form in our app, and writing this post found the one bug in them
Nakodo has no form library. No React Hook Form, no Formik, no resolver. Forms are Server Actions with useActionState, inputs are plain inputs, and everything the forms share lives in src/components/forms.tsx, which is 162 lines and exports four components and two class name strings. Fifteen files import from it. That is the whole abstraction, and it is worth writing down because the interesting decisions in a form system are not validation.
Our terms notice has no Agree button, because carrying on is the acceptance
Terms change. The question nobody enjoys answering is what should happen to the accounts that are already signed in. The common answers are all a bit wrong. A blocking modal with an Agree button interrupts someone in the middle of their work to collect a click that proves nothing, and if they do not click it you have to decide whether to lock them out of a product they are paying for. An email nobody opens is not notice. Silence is not notice either.
Our welcome card prints a five digit permit number that cannot tell you how many users we have
Nakodo is dressed as a field guide. Plans are called Notebook, Field kit and Expedition on the pricing page, creators are catalogued with numbers, and contacted ones get a rubber stamp. So when someone finishes setting up their first campaign, what they get is a permit: a framed plate with their name, their plan, the date, and a stamp that lands on it a beat later. Field permit No. 04182 Holder you@example.com Plan Field kit, Pro Issued 22 Oct 2026 Permit issued.
There are no Prices in our Stripe Dashboard, only 24 rates in a table
Our pricing page used to be a claim and our Stripe Dashboard used to be the fact. The doc comment on top of lib/pricing/plans.ts admitted it in as many words: the prices in the file and the prices on the Stripe Price objects were two copies of one fact and could drift, the file was what the site advertised, the Stripe Price was what was charged, and when you changed a price you had to change it in both places in the same sitting. That comment is gone, and so are the Price objects.
A search in our app has three identities, and the one we send over HTTP is the one that moves
In Notifio a "search" is one rental search results page the app watches for you: you paste the URL from the site, name it, and the monitor checks it on a cycle. The UI shows a row per search with its own status, countdown, session state and find count. One row. Three different keys identify it, depending on which part of the app you ask.
Three clocks in one window at 1s, 5s and 30s, and not one component uses what the hook returns
Notifio is a desktop app whose UI exists to show the state of a background process: which searches are being watched, when each was last checked, how long it took, and what turned up. Almost every number on screen is relative to now. "Last check 12s ago" is a lie four seconds later. There are two ways to deal with that. One is to store the formatted string and recompute it on an event. The other is to re-render on a timer and let the formatter read the clock.
Our activation code bans I and O, and the prefix in front of it is NTFIO
Notifio is a desktop app that watches rental search pages and tells you about new listings. It is sold as a one-time licence rather than a subscription, so there are no accounts, no passwords and no sign-in on the website at all. After checkout the server emails an activation code, and you type your email plus that code into the app on first launch. That code is the entire credential.
Three download buttons, no user agent sniffing, and a link that stops working 60 seconds after you click it
Notifio is a desktop app, so the website's real job is to hand over an installer. Those installers are large: around 600MB for each macOS DMG and 300MB for the Windows setup, because the Chromium the app drives is inside the bundle rather than something the user is asked to install. Handing over a 600MB file involves more decisions than I expected when I wrote the page. Here is the download page as it stands, and what each part of it is refusing to do.
Three font files preload on every page of our site, and on five of the six I measured one of them has nothing to render
CogniPrep's root layout loads three font families, from two different mechanisms: import { GeistSans } from 'geist/font/sans'; import { GeistMono } from 'geist/font/mono'; import { Space_Grotesk } from 'next/font/google'; const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap', preload: true, // Preload to prevent font flash in header adjustFontFallback: true, }); All three variable classes go on <html>: <html...
Our Sentry config sets one option the SDK has deleted and two more under a key whose own type says it has no effect
CogniPrep's next.config.mjs ends with this: export default withSentryConfig(configWithAnalyzer, { org: 'pertu-works', project: 'cogniprep', silent: !process.env.CI, // OPTIMIZED: Reduced from true to false to decrease bundle size widenClientFileUpload: false, // Hide source maps from being sent to the client (reduces bundle size) hideSourceMaps: true, webpack: { automaticVercelMonitors: true, treeshake: { // Automatically tree-shake Sentry logger statements to reduce bundle size...
A venue using PubTrivia puts a printed card on every table, and the card is a QR code that takes a phone straight into that night's quiz as that table. It is the joining feature, and from the host's side it is one button: say how many tables you have, get a PDF of cards to print.
Our robots.txt names eleven AI crawlers and then repeats the whole policy, because a crawler only obeys one group
Nakodo's robots.txt is 30 lines of text generated from 34 lines of TypeScript, and more than half of it is the same five rules written twice. That duplication is the only correct way to write the file, and it took reading the specification to believe it.
One palette in a package, 28 CSS variables beside it, and the file that calls itself the source of truth is still green
Munchable is a barcode scanner for people with digestive conditions. You point a phone at a pack and it answers for your own set of conditions. The whole product is one look, which the team notes call "Cookie and Cream": a cream base, cocoa ink, cookie gold and caramel for the playful parts, one pink pop for sprinkles. You can see it on the landing page, in a condition guide like the IBS and low FODMAP one, and on a recipe page such as gentle rice congee.
We store what you picked and not what you saw, so the report rebuilds itself on every open
A finished practice sitting in CogniPrep saves one row. Since we added the answer review to reports, that row carries an extra jsonb column, and what goes in it is deliberately the smallest possible thing: // What was answered, for the report's answer review: ids and choices only, // shaped as SessionResponses in lib/games/review/types.ts. Null for games // without a review and for every session saved before reviews existed. responses: jsonb(), Ids and choices.
A jsonb column the browser fills in, so every array in its schema has a max
When a practice sitting finishes, the browser posts the result, and since we added answer reviews that payload includes a new field: what you answered, as ids and choices, destined for a jsonb column. I wrote about the report rebuilding itself from that column and about why losing the column has to be cheaper than losing the score.
Four pages that name a competitor, 99 links to their own sites, and 17 table rows where the answer is no about us
Nakodo has four pages that name competitors. They are the highest risk content on the site, legally and reputationally, and they are the same data structure as every other page: one typed object in an array, rendered by the same component, checked by the same tests. This is what we did to make them safe to publish, which turned out to be mostly a writing problem with a small amount of code around it.
Twelve confirmations behind a 50 line hook, and not one of them returns a promise
Twelve places in Nakodo ask "are you sure". They all go through this: export type Ask = { title: string; text: string; action: string; // the confirm button cancel?: string; destructive?: boolean; run: () => void; }; // One confirmation dialog per component, kept filled while it animates out. // Render `dialog` anywhere in the component and call `confirm` to open it.
Two directories called public in one repo, and one of them is deleted on every build
Notifio is two things in one repository: a Next.js website at server/, and an Electron desktop app at app/. Each has a directory called public. They mean opposite things, and I did not notice until I went looking for where an icon file lived and found two candidates. The one that is a promise server/public/ is the Next.js convention: whatever is in it is served from the root of the site, unprocessed.
ARIA defines exactly one live region role that stays silent by default, and it is the one we set aria-live on
Our quiz app shows the same countdown twice: once on the host's screen behind the bar, and once on every player's phone. Same clock, same seconds, two separate components written months apart. One of them talks to a screen reader every single second. The other says nothing at all. Neither is right, and the pair of them is a decent illustration of what live regions are for.
Our quiz app has two paid plans. Changing between them used to be three steps in this order: Cancel the customer's current subscription. Open a Stripe Checkout session for the new plan. Wait for the webhook to grant it. Step 3 is optional from the customer's side. They can close the tab, lose signal in the cellar bar they were sitting in, or decide at the card form that they will think about it. The subscription they were paying for an hour ago is already gone, and nothing in the flow brings it back.
Hashing the HTML would announce all 426 of our pages on every deploy, so our IndexNow job hashes what each page says
Munchable publishes 426 URLs, and every one of them comes from a TypeScript module rather than a database. Seven condition guides, 38 recipes, 373 ingredient answer pages, and eight hand-written pages on top. You can count them yourself in the sitemap. A sitemap tells a crawler what exists. It does not tell anybody that something changed, and a crawler that visited an answer page last month has no reason to come back today.
Our maintenance switch is one boolean, and the rewrite that serves it cannot carry a 503
Munchable has a database migration coming that I do not want to run under live traffic. So before the migration, the boring prerequisite: a way to take down the parts of the site that need Postgres and a session, without taking down the parts that do not. The switch is one exported constant. // Typed as boolean, not the literal, so both settings typecheck the same way. export const MAINTENANCE: boolean = false; export const MAINTENANCE_MESSAGE = 'Munchable is down for maintenance.
A school discount with no code, because the discount is the email domain
We used to give schools a discount code. A teacher asked for one, we minted it, and then we maintained the parts that follow from a code existing: an admin CRUD for the codes, a redemption table so per-user and per-year limits could be enforced, a validate endpoint, and a support thread every time somebody pasted a code with a smart quote in it. The replacement is that a school is registered by its email domain.
We localise prices for 58 countries from a header, and both our PDF exporters are hard-coded to A4
PubTrivia prints things. Players join by scanning the code on their table, so a venue's first task is to produce a card per table, print it once and reuse it every week. There are two pieces of code that make the printable sheet: one in the dashboard that exports every table's code at once, and a free one on the marketing site that will do it for any web address at all, with no sign-up and no request leaving the browser.
Our audit log has 64 actions, 17 of them are also analytics events, and one field is deliberately dropped on the way
Nakodo has 77 call sites that look like this: await audit({ action: "campaign.launched", userId, entityType: "campaign", entityId: campaign.id }); One line, one row in audit_log. For 17 of the 64 possible actions it is also one event in PostHog, under the same name, and for one entity type a field quietly does not make the trip. The two things an action log is for There is a log of what happened because somebody has to be able to answer "who stopped this campaign and when".
One .web.tsx in the whole app, and it is 196 lines of stack navigator that no phone will ever run
Munchable's phone app is also a website. Same Expo Router tree, same screens, exported with web.output: "single" and served at app.munchable.app. Across the whole app there is exactly one platform-forked file: apps/mobile/src/components/Stack.web.tsx 196 lines, none of which ever run on a phone. This is what is in it and why none of the cheaper options worked. The two-line file that forces the question Every layout in app/ imports Stack from one place: /** * The app's stack navigator.
Our marketing pages never read your session, so the header guesses from a cookie with useSyncExternalStore
Fetch the Nakodo home page with curl and the header offers you "Sign in" and "Start free". Open the same URL in a browser where you are signed in and the header offers you "Open the app". Same HTML. $ curl -s https://nakodo.app/ | grep -o "Sign in\|Open the app" | sort | uniq -c 4 Sign in The server never knew. It is not allowed to know, and the whole reason the site is fast is that it does not ask.
We stopped asking Supabase who you are on every request, and the only thing that got worse is revocation
Munchable's marketing site and its 373 generated answer pages are statically rendered, and they still run a function on every request, because the header has to know whether you are signed in. That function used to ask Supabase Auth, in us-east-2, over the network. Now it checks a signature locally. The whole change is one module, apps/web/lib/auth/session-claims.ts. The reason it is worth writing up is not the swap, which is two lines.
PostHog's endpoints end in a slash and Next.js strips trailing slashes, so our analytics path is a negative lookahead
Here is a two line experiment you can run right now.
Three main-process files feed one renderer types file, and a comment is the only thing keeping them in step
Notifio is an Electron app. The main process runs a monitor over rental search pages, a local HTTP server, and a reply engine. The renderer is a React app that talks to that server over 127.0.0.1 and an SSE stream. Nothing else. It has two files called types.ts. src/types.ts has 26 exported types in 350 lines, and src/renderer/types.ts has 16 in 164. Ten names appear in both files. That is duplication, it is deliberate, and I spent an afternoon last week convincing myself it should stay.
We shipped a daily cap that followed last week's traffic, then deleted it seven days later
On 3 October I published a post here called Our daily API cap is three times last week's busiest day, because a fixed cap is a growth ceiling. The argument was that a constant daily cap on an outside source is a ceiling on your own growth, so the cap should follow recent use: today's limit is a multiple of the busiest of the last seven days, with a floor. On 10 October I deleted the whole mechanism. The type, the function, the lookback constant, the extra query, all of it.
A radius on a map becomes half degree cells, and the only hard parts are the pole and the antimeridian
Nakodo's business campaigns look for local businesses to partner with, which means a customer draws a circle: this town, within 20km, or this whole country. Somewhere under that is a dataset of places that has to be loaded into our database before anything can search it. Loading "a circle" is not a thing you want to do twice. Two customers both looking around Manchester should cost one import, and a customer who widens from 15km to 25km should not re-import the middle.
cwebp crops our 372 card screenshots because sips ignores --cropOffset, and sips pads them because cwebp cannot
Every practice game on CogniPrep has a card on its provider page, and every card has a screenshot of that game in play at the top of it. There are 372 of them. The McKinsey Solve page has six, Arctic Shores has fourteen. They are not captured by a headless browser in CI. They are captured by hand, because a screenshot of a game is only useful if it was taken at the moment the game looks like itself, and no script knows when that is.
One client component in our root layout wanted 56 strings, and was importing 100 KB of game metadata to get them
CogniPrep is practice for the psychometric tests and game based assessments employers send candidates. The library covers 56 assessment providers, each with its own set of practice games, and the whole catalogue lives in one module: lib/games/constants.ts, about 4,400 lines of game metadata, provider metadata and lookup helpers. That module is fine on the server. The problem was a component that needed almost none of it.
56 static folders and one dynamic segment share /games, and our proxy has to tell them apart before the router does
CogniPrep has two very different kinds of page one segment under /games. /games/arctic-shores is a public marketing page. It is statically rendered, it is indexed, it talks about a provider's assessments and it lists a card per practice game. /games/balloon is one of those games. It needs a signed in user, it checks access, it carries robots: { index: false }, and it renders a full screen runner with a timer in it. Same shape, same depth, same parent.
Our README tells you to make a Gmail app password, and nothing in the app has read one for months
Notifio is a desktop app that watches rental listing searches and tells you when something new appears. The repository has one README, in app/, and git log on it is two lines long: bf3824b Add desktop notifications and live per-search monitoring state 2026-09-22 e7f10b2 first commit 2026-07-20 Two commits, two months apart. That second commit changed 82 lines and deleted 25, and reading the diff is instructive, because of what it chose to fix.
Setting an env var above your imports does not run before them, and our integration test works anyway
The largest test file in PubTrivia is 543 lines, and it has no mocks in it. It calls the actual server actions a player's phone calls, and the actual engine the host's buttons and our WebSocket server both call, against the actual Postgres database the app uses. That is a trade with real costs, which I will get to. First, the two lines at the top, because they taught me something about module evaluation that I had believed wrongly for years.
Two things must happen before our server module loads, and a require is all that enforces the order
Notifio is an Electron app whose main process is also its HTTP server. There is no child process: app.whenReady() fires, and then one line pulls an Express application into the same process the windows live in. That line is a require, inside a function, in a file where every other dependency is a top level import. It looks like an oversight, and it is load bearing. This post is about why, because implicit initialisation order is the kind of thing that works perfectly until somebody tidies it up.
One optional phone number, eleven files, five kinds of empty, and no migration
Yesterday I added an optional phone number to Nakodo. When a creator or a business says yes to a brand, the app introduces them by email, and the brand can now add a number to go with the address. One field, one afternoon, eleven files in the commit, and a good example of where the work in a small feature actually goes.
Three platforms' logos, three sets of brand rules, and one SVG id that can blank the rest
Nakodo shows brands creators from YouTube, Instagram and TikTok in the same list. Every row has the creator's platform next to them, and that little mark turned out to be the most rule-bound component in the codebase. The reason it exists at all is not design. YouTube's Developer Policies (III.F.2) and the YouTube API Services Branding Guidelines require the YouTube logo next to data that came from the API, as a link to the content on YouTube.
Our Redis cache measured one string and stored another, so every read deleted the key
Munchable's server keeps one shaped snapshot of curated ingredient vocabulary that the apps load, plus a single version number for it. It is about 40,000 rows in Postgres, it changes a few times a day when a curation job writes, and devices ask for it constantly. So it is cached, cheapest tier first: module memory for 60 seconds per serverless instance, then Redis, then Postgres. Textbook.
A signed-in session crosses two of our domains in a URL fragment, and one consumer handles both producers
Munchable has two web origins. munchable.app is a Next.js site: the marketing pages, the ingredient guides, sign-in, checkout. app.munchable.app is the actual app, which is the Expo app built for web with react-native-web. Different origins, so nothing in browser storage on one is visible to the other. Which creates a specific moment. Somebody has just created an account on the first origin. The app they came for is on the second one.
Four email switches stored as an array of the ones you turned off, and consent that belongs to the address
Nakodo sends a brand four kinds of email about its campaigns, and each one can be switched off: export const NOTIFICATION_EMAILS = ["first_results", "daily_digest", "needs_you", "after_intro"] as const; export type NotificationEmail = (typeof NOTIFICATION_EMAILS)[number]; The list has a rule attached to it, written in the file's header: emails that always go out are not in here.
Three icon conventions in one app, and the copy-pasted one is on every sign-in screen
I went looking for how many icons our quiz app ships and found three different answers, because the app has three ways of producing an icon and nobody had ever counted them together. Convention one: 62 glyphs from a component library lucide-react is imported in 50 files by 51 import statements, and those statements name 62 distinct glyphs. The vocabulary reads like a tour of the product: QrCode, Trophy, Timer, Users, Wifi, WifiOff, Power, PowerOff, Crown, ReceiptText.
I nearly deleted four live components, because my audit grep only matched double quotes
I wanted to know which of our copied-in UI primitives were actually used, so I wrote the obvious loop: for f in components/ui/*.tsx; do n=$(basename "$f" .tsx) echo "$n: $(grep -rl "components/ui/$n\"" --include='*.tsx' app components | wc -l)" done It told me four files had zero importers: progress, separator, tabs, textarea. Dead code, 150 lines, three of them dragging a Radix package along for the ride. An easy win, and I was one command away from taking it.
One release script, two child processes, and only one of them is allowed a shell
Notifio ships as desktop installers, so releasing it is a script rather than a deploy. pnpm release bumps the version, builds the installer for whichever OS it is run on, uploads the artifacts to Cloudflare R2, and then asks R2 whether what arrived is the right size. The download page hands out presigned links to those exact objects, which is why a release overwrites a fixed set of keys instead of publishing a new versioned path each time.
Our label photos leave the phone at a 1,568 pixel long edge, and the crop happens before the resize
When a Munchable user photographs an ingredients panel, the picture that leaves the phone is not the picture the sensor took. It is cropped to the box they drew, scaled so its long edge is 1,568 pixels, saved as JPEG at quality 0.75, and base64 encoded. That is four operations and the whole function is about twenty lines. The interesting part is that two of them have to happen in that order and it took a bad build to notice.
Expo in a pnpm monorepo: one flat node_modules, two peer overrides, and the Metro flag you should not set
Munchable is a pnpm workspace with two apps and five packages in it. One app is Next.js. The other is an Expo app whose web build is also our web app. Three of the five packages are imported by both of them. Getting Metro to agree with that arrangement took three configuration decisions, and the most useful one was choosing not to set a flag that every monorepo guide tells you to set.
Four of our expo-router screens sit outside the authenticated group, so the gate had to become a hook
Munchable's phone app has one authenticated area and a handful of modal screens that are not inside it. That is a deliberate navigation choice and it quietly undid our auth gate.
Show More
loading
Actions
Is this you?
As a journalist, you can create a free Muck Rack account to customize your profile, list your contact preferences, and upload a portfolio of your best work.Get in touch with Daniel
Contact Daniel, search articles and posts on X, monitor coverage, and track replies from one place.
Learn more about Muck Rack