DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two routes in our app return 404 in production on purpose, and they are how every card image gets made

Nearly every practice card on CogniPrep's public pages shows a still of the thing in play. The SHL hub has one per test, and the numerical reasoning format page reuses the same images across providers. They are not mockups and they are not hand-built. They are browser screenshots of the real screens.

Which raises a problem. The real screens need a signed-in account with the right entitlements, they are reached through a dashboard, and they are the last place you want a capture script authenticating and clicking its way through a purchase flow.

So there are two routes that exist only on a developer's machine.

The whole gate is one line

export const metadata: Metadata = { robots: { index: false, follow: false } };

/**
 * Local-only game runner with no sign-in, used to capture the card screenshots
 * (scripts/game-screenshots.ts). `next dev` only: every other build returns 404,
 * and the question bank API skips its auth check under the same condition.
 */
export default async function GamePreviewPage({ params }: { params: Promise<{ 'game-name': string }> }) {
  if (process.env.NODE_ENV !== 'development') notFound();

  const { 'game-name': gameName } = await params;
  const game = GAME_LIBRARY.find((g) => g.id === gameName);
  if (!game) notFound();

  return <GameRunner gameId={game.id} ... />;
}
Enter fullscreen mode Exit fullscreen mode

NODE_ENV is set to production by next build, so the page compiles into the production bundle and then refuses to render. You can check from here:

await fetch('https://cogniprep.app/dev/game-preview/balloon').then(r => r.status);
// 404
Enter fullscreen mode Exit fullscreen mode

Both preview routes answer that way in production. The sibling route for assessment centre exercises does the same, with one difference worth copying: the exercise runner takes the whole exercise as a prop, so unlike the game preview no API route had to be opened up at all. Only saving a finished attempt would fail, and a capture never gets that far. When you are building a local-only door, it is worth checking whether the thing behind it even needs one.

Why the noindex tag is there anyway

A page that returns 404 to everyone on the internet cannot be indexed. The metadata export is still there, and I would keep it.

It costs one line and it is correct in the state the page actually spends its life in: running locally, served by next dev, occasionally with a tunnel pointed at it. It also says something to the next reader that the notFound() call does not, which is that this page is not meant to be found by anyone. Belt and braces are cheap when the brace is a literal.

The arithmetic is the hard part

The capture script runs against pnpm dev, in a dark-mode viewport at device pixel ratio 2, and the numbers are not round for a reason:

const TARGETS = {
  game: { viewportHeight: 800, headerHeight: 64, footerHeight: 0, ... },
  exercise: {
    // The e-tray's three panes run the full width, so a 16:10 trim would cut
    // text off both edges. 916 is 800 plus ExerciseContainer's h-14 header and
    // the workspace's 60px "Finish & score" bar, which leaves an area of exactly
    // 1280x800 to keep whole between them.
    viewportHeight: 916, headerHeight: 56, footerHeight: 60, ...
  },
};
Enter fullscreen mode Exit fullscreen mode

The output is 16:10, so the usable area has to be 16:10 before anything is cropped. For a game, 1280 by 800 minus a 64px header leaves a region that trims cleanly. For an exercise, the header is 56px and there is a 60px action bar pinned to the bottom, so the viewport is grown by 116px rather than the crop being tightened. Same output, opposite move: one subtracts chrome, the other budgets for it.

Two more defaults that fell out of looking at the results:

  • The default crop trims to 16:10 around the centre of the usable area. A full mode keeps the whole area and pads it to 16:10 instead, which is what a layout running edge to edge needs. Cropping those would have cut the edges off the content.
  • A CSS-pixel box can be passed per capture to crop tighter onto one region. That is the escape hatch for the handful of screens where the interesting part is a third of the frame.

At dpr 2 a 1280 wide viewport gives a 2560 wide PNG, which is then resized to 960 and written as webp. The cards on the page request far less than that, so the extra resolution is only there to survive the resize.

The manifest is the part that stops a card lying

Capturing an image is not the end of it. A card has to know whether an image exists, and a card that hardcodes a filename will eventually point at a file nobody made. So the script has a second subcommand that writes the list:

pnpm tsx scripts/game-screenshots.ts crop [--exercise] <png> <id> [full | x y width]
pnpm tsx scripts/game-screenshots.ts manifest
Enter fullscreen mode Exit fullscreen mode

manifest reads the output directory and generates a ReadonlySet<string> of the ids that have an image, with a do-not-edit-by-hand note at the top. The card component imports it and asks GAME_SCREENSHOTS.has(game.id) before it renders an <Image>, so adding a screenshot is a capture plus a regenerate, and a missing one degrades to the no-image layout rather than a broken image. The generator reads the filesystem at build time, which the cards cannot do, and the constant is the bridge between them.

None of this is clever. It is just three things kept honest at once: the page that exists in one environment, the arithmetic that makes a crop land where you meant it to, and a list nobody types by hand.

Top comments (0)