Someone arrives on our site from a search for a specific assessment provider. They read the page, they sign up, and the dashboard shows them a generic welcome with twenty four providers to choose from.
They came here for one thing. We knew which thing. Then we threw it away at the signup boundary.
The fix is seventy lines and it is deliberately the least serious piece of code in the repository. I want to walk through it because "small feature done with the right amount of engineering" is a thing I see done wrong in both directions constantly.
The whole thing
export type EntryIntentInput =
| { kind: 'provider'; id: string }
| { kind: 'assessment-centre' };
export type EntryIntent =
| { kind: 'provider'; id: string; at: number }
| { kind: 'assessment-centre'; at: number };
const STORAGE_KEY = 'cogniprep:entry-intent';
const ENTRY_INTENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
A tracker component records the intent when you land on a qualifying page. The dashboard reads it and greets you with the thing you came for rather than a default. That is the entire feature.
The interesting parts are all constraints.
It is allowed to be wrong
The doc comment states this outright, and it is the sentence that licenses every other decision in the file:
It is purely a personalisation hint. It gates nothing and carries nothing sensitive: only a known provider slug or the assessment-centre sentinel.
Because it gates nothing, localStorage is fine. Because it gates nothing, silent failure is fine. Because it gates nothing, it does not need a database row, a server round trip, a migration, or a cookie consent conversation.
That last point is the one people get wrong in the expensive direction. The instinct is "we should persist this properly", which means a table, an API route, a write on page load, and now you have a user-identifying write happening before the user has an account. All to decide which heading to render.
The opposite mistake is using the same mechanism for something that does gate. The moment this value decides what somebody is allowed to access, localStorage is a user editable input and the whole design is wrong. The comment exists to stop the next person crossing that line by accident.
First touch, not last touch
export function recordEntryIntent(intent: EntryIntentInput): void {
if (typeof window === 'undefined') return;
try {
if (readEntryIntent()) return; // first-touch wins
const value: EntryIntent =
intent.kind === 'provider'
? { kind: 'provider', id: intent.id, at: Date.now() }
: { kind: 'assessment-centre', at: Date.now() };
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// Ignore storage errors (disabled/full/unavailable localStorage).
}
}
if (readEntryIntent()) return is the load bearing line. Without it, the last page you happened to look at before signing up wins.
Think about the actual browsing session. You search for a specific provider, land on its page, and that is your intent. Then you click around, because that is what people do on a site that has twenty four of something. By the time you sign up you might be three pages away from what you came for.
Last touch would record the last thing you idly clicked. First touch records what you searched for. For attribution and intent, earliest is almost always the better signal, and it is one line.
Note the check happens inside the try, so a storage failure during the read cannot cause a double write.
It expires by itself
export function readEntryIntent(): EntryIntent | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as EntryIntent | null;
if (!parsed || typeof parsed.at !== 'number') return null;
if (Date.now() - parsed.at > ENTRY_INTENT_MAX_AGE_MS) return null;
if (parsed.kind === 'provider' && typeof parsed.id === 'string' && parsed.id.length > 0) {
return parsed;
}
if (parsed.kind === 'assessment-centre') return parsed;
return null;
} catch {
return null;
}
}
localStorage has no TTL, which is why so much of it is permanent litter. Storing the timestamp alongside the value and checking it on read is the cheap substitute, and it means the staleness rule lives in one place instead of at every call site.
Thirty days is the answer to "how long should a visit still be pinning your dashboard". Without it, someone who looked at one provider in March gets greeted about it in September, which reads as the product being confused rather than helpful.
The validation on the way out matters more than it looks. This value is user editable, so readEntryIntent treats it as untrusted input, not as something it wrote itself. It re-checks the discriminant, re-checks that at is a number, and re-checks that the provider id is a non-empty string. Anything unrecognised returns null and the user gets the default experience.
JSON.parse on a localStorage value without a try/catch is one of the most common runtime errors in frontend code. Any extension, any older version of your own schema, any half written value from a tab that was closed mid-write, and you have thrown inside a render.
SSR and storage failures are the same shape of problem
Every function starts with if (typeof window === 'undefined') and wraps the rest in try/catch. This is the discipline that makes a browser-only module safe to import anywhere in a Next.js app.
The failure modes are more common than people expect. Safari in private mode has historically thrown on setItem. Storage can be full. Enterprise policy can disable it. A user can have it off entirely.
None of those should break a page render, and for a feature whose entire job is to choose a heading, all of them should be completely silent. The two empty catch blocks in this file are justified by the comment above them, which is the difference between "handled" and "swallowed".
The type that does not lie
export type EntryIntentInput = { kind: 'provider'; id: string } | { kind: 'assessment-centre' };
EntryIntentInput has no at. EntryIntent does.
Kept separate from
EntryIntentso producers never have to invent anatthey don't own.
If there were one type with an optional timestamp, every caller would have to decide whether to pass Date.now(), and sooner or later one of them would pass something else: a page load time, a cached value, a server timestamp in the wrong units. Two types means the caller describes what and the module owns when.
It is two extra lines. It removes an entire category of mistake, and it makes the round trip through authentication well typed, because the input shape is exactly what survives a cookie hop while the timestamp is re-established on the other side.
Try it in about a minute
This is visible from the outside. Open a specific provider page, say the Aon page, or the assessment centre practice page, then sign up. The dashboard should lead with the thing you arrived for instead of a generic list.
Then open devtools, look at localStorage for cogniprep:entry-intent, and you will find the whole feature: one key, one small JSON object, one timestamp. Edit it to nonsense and reload. Nothing breaks, you just get the default.
That last property is the one I would want any personalisation feature of mine to have.
Top comments (0)