Six days ago I published a post about this app having one file per page, and it opens with this sentence:
This app has 94
page.tsxfiles, 7 layouts, and not one dynamic segment. No[slug], no[...path], nogenerateStaticParams.
The first number was right, the last clause was right, and the middle claim was false when I wrote it. PubTrivia had two dynamic segments at the time, and two more shipped three days later.
Here is the recount, and the rule that explains where they are allowed to be.
What the grep says today
$ find app -name page.tsx | wc -l
109
$ find app -name layout.tsx | wc -l
8
$ find app -path '*[*' -name page.tsx
app/play/[venueId]/[tableId]/page.tsx
app/dashboard/packs/[id]/edit/page.tsx
app/admin/venues/[id]/page.tsx
app/admin/quizzes/[id]/page.tsx
$ grep -rn generateStaticParams app lib | wc -l
0
Four dynamic page routes, five dynamic segments between them, and nothing anywhere that enumerates their values at build time.
The dates matter for the correction. git log --diff-filter=A says the player route was added on 3 June 2026 and the pack editor on 2 June. They predate the post by five months. The two admin routes were added on 4 November, in the commit that built an internal dashboard.
So the claim was not "true then, false now". It was wrong, in a post full of correct measurements, because the measurement covered the 72 URLs in the sitemap and the sentence generalised it to the repository. The version of that claim which was true, and is still true at 79 URLs, is narrower: every page we want ranked is a directory on disk with a file in it. None of the four dynamic routes is in the sitemap, so none of them is in that set.
The rule the four obey
Laid out together, the four are not arbitrary:
| route | the id in the URL comes from |
|---|---|
/play/[venueId]/[tableId] |
a QR code printed on a table card |
/dashboard/packs/[id]/edit |
a row the user created |
/admin/venues/[id] |
a row an operator is inspecting |
/admin/quizzes/[id] |
a row an operator is inspecting |
A dynamic segment appears exactly where the URL carries an identifier that nobody typed. Every URL a human might type, which is every page in the sitemap, is a folder. That is a rule worth stating out loud, because it answers the question "should this be a [slug] route" without an argument: if the path is something a person could compose in their head or read off a search result, it is a directory.
It also explains why generateStaticParams is absent rather than forgotten. Prerendering a dynamic route means knowing the ids at build time. One of these is a set of rows that changes while a quiz is running, two of them are private records, and the fourth is a pack somebody is in the middle of editing. There is nothing to enumerate.
What each one says to a stranger
Three of the four never reach their own code for a signed-out visitor. The gate in front of the app redirects them before any HTML exists, and preserves where they were going:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
https://pub-trivia.app/admin/quizzes/bfbcccc8-740b-4aae-ac96-442b59a3cb15
307 https://pub-trivia.app/login?next=%2Fadmin%2Fquizzes%2Fbfbcccc8-740b-4aae-ac96-442b59a3cb15
The same for /admin/venues/<id> and /dashboard/packs/<id>/edit. A signed-in account that is not meant to see the admin pages gets a 404 rather than a 403 from the layout, so the URL does not confirm that anything is there.
The fourth is the interesting one, because it answers 200 to any two strings in the world:
$ curl -s https://pub-trivia.app/play/not-a-uuid/also-not | grep -oE '<h1[^>]*>[^<]*'
<h1 class="text-2xl font-bold">Table Not Found
That is deliberate, and the reason is a pub rather than a spec. A player's entire interaction with that URL is holding a phone over a bit of card. When the card is out of date, or the venue deleted the table, or the QR got reprinted and someone kept the old one, a 404 is useless: the person holding the phone cannot tell whether they scanned wrong, the quiz has not started, or the app is broken. So the route validates its own parameters, finds nothing, and returns a page that says what to do next:
This QR code doesn't match any active venue or table. Please ask your host for help.
There is a second branch above it for a different failure. A whole room scanning at the same moment can trip the page-load limiter, and getting "Table Not Found" because sixty other people scanned first would be a lie. That case gets its own screen telling you to wait a few seconds and refresh.
The bug the recount found
Because the route answers for any input, it is also the one page in the app a crawler is most likely to meet with junk in the URL, and it ships no title of its own. The segment has a layout whose entire metadata export is this:
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
Its comment explains why that is not redundant with robots.txt:
Player URLs are handed out on table cards and shared between phones, so they escape into the wild and get crawled.
index: falsecovers every route under /play; robots.txt disallows the segment as well, but that only stops crawling, and a URL Google has seen elsewhere can still be indexed title-only, and it can never fetch a disallowed page to discover a noindex added afterwards.
Right conclusion, and it leaves a gap. There is no title, so the title falls through to the root layout's default, and the page a player stares at while their phone loads is called:
$ curl -s https://pub-trivia.app/play/not-a-uuid/also-not | grep -oE '<title>[^<]*'
<title>Pub Quiz App for Pubs and Bars | PubTrivia
A marketing title, keywords included, on the one screen whose reader has already chosen the product and is holding a pint. It is noindexed, so this is not an SEO problem; it is a tab label, a bookmark name, and the string a phone's app switcher shows. "Join the quiz" would be four words of work.
What I would take from this
"Not one" is a claim about a grep, and the grep was find app -path '*[*' -name page.tsx, which takes one second. I did not run it, because I had just spent an hour measuring 72 pages and the shape of the answer felt obvious.
The failure mode is specific and worth naming: a post full of real measurements earns a tone of authority that the unmeasured sentences then borrow. The fix is not more care in general, it is to check that every countable noun in the opening paragraph came out of a command.
Check it yourself
The narrow claim is verifiable in one line. Every URL we submit for ranking is a path somebody could have typed, and none contains an id:
$ curl -s https://pub-trivia.app/sitemap.xml | grep -c '<loc>'
79
$ curl -s https://pub-trivia.app/sitemap.xml | grep -cE '<loc>[^<]*[0-9a-f]{8}-'
0
Then try the fourth route with anything you like in the path, for example /play/no-such-venue/no-such-table, and you will get a 200 and a sentence rather than a 404. The pages that do have typed URLs are all in the guides index and the features index, one folder each.
Top comments (0)