CogniPrep is a Next.js app with 76 route handlers and 10 Server Actions. The actions live in three files totalling 518 lines, which is not much next to the rest of the app, so I went through all ten to work out what rule had been applied.
app/auth/actions.ts 382 lines 8 actions
app/dashboard/settings/actions.ts 83 lines 1 action
app/dashboard/games/actions.ts 53 lines 1 action
The rule turned out to be: an action if the browser does not need the answer, a route handler if it does. Signing in, signing out, sending a reset email, changing a password and spending an unlock token all finish with a redirect or a cache invalidation. Everything else is a fetch to one of the 76 routes, 46 call sites across 22 files, because the caller wants JSON back.
Counting forms is harder than counting actions
My first measurement was wrong. Grepping for action={ in the app returns 75 matches, which looked like 75 forms wired to actions. It is five.
The other 70 are a card component whose header slot is also called action:
<GamePanel
action={
<Button variant="ghost" onClick={onSkip}>Skip</Button>
}
/>
The whole app has 16 <form> elements. Five of them pass a Server Action to action, and two call useActionState. For a product with an account system, a settings page, a support desk and a checkout, that is a very small number of forms, and it is because almost every input in the app is a controlled React field posting through fetch.
Two wiring styles, visible in the deployed HTML
The five form-bound actions split into two groups, and you do not need the source to tell them apart. Fetch a page and count the hidden inputs React emitted.
curl -s https://cogniprep.app/login | grep -o 'name="\$ACTION[^"]*"'
curl -s https://cogniprep.app/forgot-password | grep -o 'name="\$ACTION[^"]*"'
/login returns two lines, one per provider button:
name="$ACTION_ID_00a2cac2cdc57db0da1..."
name="$ACTION_ID_00cfe4e0df2fc16e467..."
Those are the Google and GitHub buttons, each a one-element form whose action is the imported action itself. React serialises the action's id into the form, so the POST target exists in the HTML before any JavaScript runs.
/forgot-password returns four lines instead:
name="$ACTION_REF_1"
name="$ACTION_1:0"
name="$ACTION_1:1"
name="$ACTION_KEY"
That is the useActionState shape. The form carries a reference plus the serialised previous state, because the action's contract is (state, formData) and the state has to survive the round trip.
Both shapes submit with method="POST" and encType="multipart/form-data" to action="", which is the current URL. Same page, two different sets of hidden inputs, decided entirely by how the action was passed in.
The two forms with no action markup at all
/signup returns the same two provider ids as /login and nothing else, even though both pages have an email form. Two reasons stack up.
The first is that the email form sits behind a "Continue with Email" toggle in React state, so it is not in the server-rendered HTML at all.
The second is more interesting. loginUser and signup are written with the useActionState signature:
export async function loginUser(
_currentState: { message: string },
formData: FormData
) {
but neither form uses the hook. They submit through onSubmit and call the action like an ordinary async function:
const formDataObj = new FormData(e.currentTarget);
const result = await loginUser({ message: '' }, formDataObj);
So the first parameter is there to satisfy a hook that is not used on those two pages, and the call site supplies { message: '' } by hand to fill the slot. The underscore in _currentState is the tell: four of our actions take that parameter, two are wired to useActionState, and two have a literal passed into it forever.
That is not a bug, and the forms work. It is the cost of keeping one signature across four auth actions when only half of them are hook-driven, and the consequence is that the login and sign-up forms need JavaScript before they can submit anything, while the Google and GitHub buttons on the same page do not.
The tenth action, which touches no form
The one worth keeping is the one with no form anywhere near it. Unlocking a provider from the dashboard spends a token, and it is a Server Action for reasons that have nothing to do with progressive enhancement:
export async function spendProviderToken(
provider: string
): Promise<{ success: boolean; error?: string }> {
It takes a typed string and returns a typed result instead of a Response. It ends with revalidatePath('/dashboard/games'), so the server data behind the caller refreshes without the client knowing which paths to bust. And the check and the spend are one conditional UPDATE rather than a read followed by a write.
Written as a route handler, the same thing needs a URL, a request schema, a JSON body, an HTTP status to carry "insufficient tokens", and a client-side parse. None of that would have made it safer.
One action we forgot to delete
Sign-out exists twice: once as the action two components use, and once as a POST route handler that does the same three operations and has zero callers in the app. Its file also sits in a directory path with a repeated segment, which is a good sign nobody has opened it in a while.
That is the actual lesson from counting. The split between actions and route handlers in this app is defensible and mostly deliberate, but the duplicates do not announce themselves in either direction, because a dead Server Action and a dead route handler both typecheck forever.
If you want to try the counting on your own app: grep for 'use server' to find the files, grep for <form action={ rather than action={, and then read the hidden inputs in the deployed HTML, which is the only place the two wiring styles are actually distinguishable.
Top comments (0)