DEV Community

Daniel Pertu
Daniel Pertu

Posted on

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. The route folder looks like this:

app/games/
  arctic-shores/page.tsx
  shl/page.tsx
  ... 54 more provider folders
  [game-name]/page.tsx
  page.tsx
Enter fullscreen mode Exit fullscreen mode

Why this works at all

Next.js resolves static segments before dynamic ones. /games/shl matches the shl folder even though [game-name] would also match it, so the 56 provider folders win against the catch all sibling and the dynamic route only sees what is left over.

That is the whole trick, and it buys something worth having: the URL a candidate searches for, cogniprep.app/games/shl, is also the URL they land on from Google, with nothing like /providers/ in it, while the game they then press Start on lives one level deeper in the same namespace rather than in a parallel /play/ tree.

The cost is that the router is no longer the only thing that has to make this decision.

Middleware runs before routing

Next 16 renamed middleware to proxy.ts. Whatever it is called, it runs before the router has matched anything, so it does not know whether a path resolved to a static folder or a dynamic segment. It gets a string.

Our proxy has to know, because the two kinds of page have completely different needs. The provider page is static HTML and needs nothing. The runner needs a session and the database.

So the decision is written again, in five lines:

/**
 * `/games/<provider>` is a static marketing page; `/games/<game-id>` is the game
 * itself, which needs a signed-in user. Provider slugs are exactly the static
 * folders under app/games, so anything else in that position is a game.
 */
function isGameRunner(pathname: string): boolean {
  const [, section, slug] = pathname.split('/');
  return section === 'games' && Boolean(slug) && !isAssessmentProvider(slug);
}
Enter fullscreen mode Exit fullscreen mode

isAssessmentProvider is a lookup in the slug list the static folders mirror, which is what keeps this honest: there is one list, the folders match it, and a test fails if the list drifts from the provider metadata it mirrors.

Note the direction of the default. Anything in that position that is not a known provider is treated as a game. /games/not-a-real-thing is a game as far as the proxy is concerned, even though the page it reaches will notFound(). That is the safe direction: the fallback assumes the heavier requirements, so a typo is treated as if it needed the database rather than the reverse.

What actually depends on the answer

Not the auth boundary. The protected prefixes are short:

const PROTECTED_PREFIXES = ['/dashboard', '/exercises'];
Enter fullscreen mode Exit fullscreen mode

/games/* is not in there. Everything under /games is public at the proxy level, and the runner guards itself in the page, where the user has to be resolved anyway:

const user = await getAuthenticatedUser();
if (!user) redirect('/login');
Enter fullscreen mode Exit fullscreen mode

Public by default, with the gate at the page, means adding a new marketing page needs no proxy change at all. That is a property worth protecting, and it is why the proxy is not where the games are gated.

What isGameRunner is actually for is maintenance mode. There is one boolean in the repository that takes the signed in app offline, and while it is on, nothing is allowed to reach Supabase or Postgres. But most of the site does not need either:

const isAppPage =
  isProtectedRoute || hasPrefix(pathname, MAINTENANCE_PREFIXES) || isGameRunner(pathname);
Enter fullscreen mode Exit fullscreen mode

Marketing pages stay up and keep serving. API routes answer 503 with Retry-After, which both Stripe and our mail provider treat as retry rather than failure, so no webhook is lost. App pages are rewritten to /maintenance, which keeps the URL in place so a reload after it is over lands the user back where they were.

So during maintenance, the SHL provider page is still a working page and still gets indexed, and the game it links to serves the maintenance notice. One segment, two behaviours, decided by a set membership test.

The general version

If you put two kinds of page at the same URL depth, you are relying on routing precedence, and routing precedence is a thing only the router knows. Every layer that runs before the router, which in practice means middleware and anything doing URL based analytics, needs its own copy of the discriminator.

That copy should be derived from the same source as the routes themselves. Ours is a list of 56 slugs that the folders mirror, with a test asserting the list matches the provider metadata. The version of this that goes wrong is the one where the discriminator is a hardcoded array in the middleware file, and a new provider page silently becomes a game.

The 56 provider pages are all listed on cogniprep.app/games if you want to see what is on the static side of the line.

Top comments (0)