CogniPrep has four things you can arrive for: a specific test provider's practice games, assessment centre exercises, interview practice, and as of this week an application tracker. Someone who lands on a guide about internship deadlines and then signs up should not be dropped on a generic dashboard, and they definitely should not be dropped into a numerical reasoning game.
So the site remembers what you first arrived for, in one localStorage key, and signup reads it. The whole thing is about 100 lines and most of the interesting decisions are about precedence.
First touch, never overwritten
const STORAGE_KEY = 'cogniprep:entry-intent';
/** How long a recorded entry intent stays usable (30 days). */
const ENTRY_INTENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
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: intent.kind, at: Date.now() };
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// Ignore storage errors (disabled/full/unavailable localStorage).
}
}
First touch rather than last touch, because the question being answered is "what did you come here for", and browsing three more sections afterwards is not new information about that. The same repository has a last-touch key right next to this one, for referral codes, where the opposite rule is correct: the last link you clicked is the one whose owner gets credit.
Every read and every write is inside a try. A visitor with localStorage disabled gets no personalisation and no error, and readEntryIntent returns null for anything it cannot parse, anything missing a numeric at, and anything older than 30 days. It is a hint, so a hint that fails is a shrug.
The sections are three fixed strings
export const SECTION_INTENTS = ['assessment-centre', 'interview', 'applications'] as const;
const SECTION_PREFIXES: { prefix: string; intent: SectionIntent }[] = [
{ prefix: '/assessment-centre-practice', intent: 'assessment-centre' },
{ prefix: '/interview', intent: 'interview' },
{ prefix: '/application-tracker', intent: 'applications' },
];
export function sectionIntentForPath(pathname: string): SectionIntent | null {
const match = SECTION_PREFIXES.find(
({ prefix }) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
return match?.intent ?? null;
}
The prefix test is "exactly the prefix, or the prefix followed by a slash", never a bare startsWith, which is the difference between matching /interview plus its guides and also matching some future /interviewing-tips.
Matching the guides and not just the hub is the entire reason this is a prefix table rather than a list of three paths. The original version keyed off one page, the assessment centre landing page, and that is not where anyone arrives. Search traffic lands on the guide: somebody looking for "e-tray exercise tips" or "spring week application deadlines" enters on a page three levels down and has never seen the hub. Pricing the hub as the only entry point means the signal fires for almost nobody.
Two recorders, because some pages cannot say what they are about
One component sits in the root layout and works entirely off the pathname, re-checking on every client-side navigation:
-
?provider=<slug>on any page, which is how a signup link can carry intent; - a product section's hub or any guide beneath it;
-
/games/<provider>or/games/<gameId>, mapped back to its provider.
Then there is a second, explicit recorder for pages whose subject is not in their URL. /employers/pwc says nothing about which test provider PwC uses. The mapping from employer to assessment lives in a 1,700-line evidence file that has no business being imported into a client bundle, so the server component that already read it passes the answer down as a prop.
A blog post is the same shape of problem, and this week it got the same treatment:
<EntryIntentRecorder section={blogEntrySection(post)} />
export function blogEntrySection(post: BlogPost): SectionIntent | null {
if (post.entrySection) return post.entrySection;
return post.category === 'Interview Prep' ? 'interview' : null;
}
A category we already maintain does most of the work: all six Interview Prep posts are interview arrivals without anybody typing anything. The override field exists for the posts where the category is wrong about the section, and there are exactly three of them, all assessment centre pieces filed under Guide, Tips and Preparation.
The props are deliberately bare strings rather than one object:
export function EntryIntentRecorder({
provider = null,
section = null,
}: {
provider?: string | null;
section?: SectionIntent | null;
}) {
An EntryIntentInput object prop would be a fresh reference on every render of the page around it, and the effect would re-run every time. Two primitives in the dependency array re-run when the page actually changes. A provider wins if both are supplied, being the more specific signal.
Crossing the auth boundary turns it into a cookie
localStorage does not exist on the server, and the decision about where to send someone has to be made during an OAuth callback. So just before authentication starts, a client component copies the stored intent into a cookie, encoded by the same module that decodes it:
const PROVIDER_VALUE_PREFIX = 'provider:';
export function encodeLandingIntent(intent: EntryIntentInput): string {
return intent.kind === 'provider' ? `${PROVIDER_VALUE_PREFIX}${intent.id}` : intent.kind;
}
The prefix is there so the reader can tell the two arrival types apart without relying on a section sentinel never colliding with a future provider slug. Nothing today is called interview, but a provider might be, and a bug where signing up sends you to the wrong product because somebody added a provider is not a bug I want to debug a year from now.
next beats the cookie, and the reason is staleness
export function postAuthDestination({ next, landingIntent }): string {
return internalPath(next) ?? destinationForLandingIntent(landingIntent, DEFAULT_DESTINATION);
}
Two signals can claim the redirect and they are strictly ranked. An explicit next is a deep link the user clicked seconds ago: an email confirmation, a "sign in to continue" bounce off a specific page. The cookie's source is a first-touch record that can be 30 days old. Get that order wrong and a returning visitor who once read a provider page is dropped into a practice game no matter which link they actually clicked, with their next discarded in silence.
Neither path can become an open redirect, and the two defences are different in kind. next is a path we were handed, so it is validated:
function internalPath(next: string | null | undefined): string | null {
if (!next || !next.startsWith('/')) return null;
if (next.startsWith('//') || next.startsWith('/\\')) return null;
return next;
}
//evil.com is protocol-relative and would navigate off site. The backslash variant is there because the URL parser folds \ into / for http(s) URLs, which makes /\evil.com equivalent to //evil.com. One leading slash is not enough of a check.
The cookie gets a stronger guarantee: its value is never used as a path at all. It selects from a fixed map of three internal routes, or it is a provider slug that has to resolve through the game library:
const SECTION_DESTINATIONS: Record<SectionIntent, string> = {
'assessment-centre': '/dashboard/exercises',
interview: '/dashboard/interview',
applications: '/dashboard/applications',
};
An attacker-supplied cookie can therefore do exactly one thing: fall through to /dashboard.
That function lives on its own, pure and cookie-free, because two very different callers need the same answer: an OAuth route handler that clears the cookie on its own NextResponse, and an email signup server action that clears it through cookies(). They used to each decide their own fallback, and they disagreed. Sharing the lookup would not have fixed that; sharing the whole decision, precedence included, did.
See it
This is all client-side and visible, so you can watch it happen. Open cogniprep.app/application-tracker/internship-applications, open the console, and read the key:
localStorage.getItem('cogniprep:entry-intent');
// '{"kind":"applications","at":1793784088594}'
Now navigate to a provider hub such as /games/aon and read it again. Same value, same timestamp: first touch wins, and browsing does not overwrite it. Clear the key, reload on the provider hub, and you will get {"kind":"provider","id":"aon",...} instead.
Worth trying on two blog posts as well. An Interview Prep post such as /blogs/amazon-interview-preparation-guide writes {"kind":"interview"}. An assessment centre post with the override, /blogs/how-e-tray-and-in-tray-exercises-are-scored, writes {"kind":"assessment-centre"}.
And one that writes nothing at all: /blogs/aon-cut-e-test-tips, a post entirely about one test provider, leaves the key empty. The blog shell can pass a section down and has no way to pass a provider, because the registry entry has no field for one. A visitor who arrives on a provider's own article is, as far as this feature is concerned, not here for anything in particular. That is the next thing to fix, and it is a one-field change rather than a clever one.
Top comments (0)