When does an app genuinely need its own HTTP endpoint instead of doing the work inside the server-rendered route?
answer
- ask who has to call it
- an outside caller, or a non-document response
- otherwise server code calls the module directly
- an endpoint is a contract you then keep
basics
~20 sOnly when a caller you do not control must reach it by URL - a third-party callback, a native client, a script - or when the response is not a document. Otherwise the route's own server code is the cheaper surface.
solid answer
~50 sI apply a two-part test. An endpoint is justified when a caller I cannot redeploy has to reach the behaviour **by URL** - a third-party callback, a native or desktop client, an automation script, a partner's server - or when the **response is not a document**: an export, an image, a feed, a redirect, a chunked stream. Where neither holds, the route's own server code can call the same module directly during the render, or the route's own write handling can take the submission, and that costs no network hop, no serialisation, no second validation and no new public surface. The last point matters most over time: an endpoint is a **contract**. Once something outside my deploy pipeline calls it, its path, method and payload shape are frozen. Modules I can rename; URLs I cannot.
go deeper
Remember the shape of the question: an endpoint exists for callers who can only reach you by URL. If the only caller is the page being rendered, the server code can just do the work.
Explain the two-part test and what the alternative costs: a hop, a serialisation boundary, a second validation, and a URL that becomes public. Be able to name a legitimate outside caller.
Show that you recognise the loopback smell in a real codebase - server-side code calling its own deployment over HTTP - and can describe moving the rule into a shared module without breaking existing callers.
Talk about the asymmetry: adding a URL takes minutes, withdrawing one takes a deprecation window. Set the review bar in proportion to that, and to whether another service should own the behaviour instead.
Putting an HTTP endpoint inside the application that serves the pages is cheap to do and therefore easy to over-do. The discipline worth carrying into an interview is a **two-part test**, plus a default for everything the test does not catch. ## The test An in-app HTTP endpoint is justified when **at least one** of these holds: 1. **A caller you do not control must reach the behaviour by URL.** A third-party service delivering a callback, a native or desktop client, an automation script, a partner's server, markup embedded on someone else's site, a health probe. None of these can import your module; a URL is the only interface they can use. 2. **The response is not a document.** A generated export, an image, a feed, a redirect used as a short link, a chunked stream. The page pipeline produces HTML; anything else needs a route that is allowed to answer with something else. Where **neither** holds, the cheaper surface is the one already there: the route's own server code reading what it needs during the render, or the route's own write handling for a submission that originates in your interface. Both run in the same process as the page, so they call the shared module directly. ## Why "cheaper" is not just a phrase | | Direct call from the route's server code | Call through an in-app endpoint | |---|---|---| | Network hop | none — an in-process function call | a full HTTP round trip to your own deployment | | Failure modes | the function's own | plus timeouts, retries, partial responses | | Type safety | the module's types hold end to end | a JSON boundary re-validated on both sides | | Serialisation | none | encode, send, decode | | Public surface | zero — an internal module | a URL anyone can call, indefinitely | | Authorisation | inherited from the render's context | must be decided again, in the handler | The last two rows are the ones that bite later. **Every endpoint is a contract**: once it exists and something outside your deploy pipeline calls it, its path, its method, its accepted payload and its response shape are frozen for as long as that caller lives. An internal module carries no such obligation — you rename it, change its signature, and the type checker tells you who cared. ## The symptom to recognise The classic shape is an application whose pages fetch their own data from their own endpoints over HTTP during or after the render. The request leaves the deployment and comes back to it. The team pays the latency, the extra failure mode and the duplicated validation, and gains an interface no outside caller ever asked for. The fix is not to remove the ability but to move the **rule** into a shared server module, and let both the render and any genuinely-justified endpoint call it. ## Where the test is less clean - **Client-side interaction after the first render.** A filter, an endless list, a type-ahead: once the page is running in the browser, the browser genuinely cannot import a server module, so *something* has to be reachable by URL. Frameworks differ in what they offer — some provide a typed server-call mechanism that generates the transport for you, others expect an explicit endpoint. Where the typed mechanism exists it is usually preferable, because it stays refactorable and is not a public contract in the same way; where it does not, an endpoint is the honest answer. - **A shared operation with two audiences.** When the same operation must be triggered from your own interface and by an outside caller, the answer is not to pick one surface — it is to implement the rule once in a module and give it two thin edges. - **Build-time needs.** A build step reads files and calls modules directly; it never needs an HTTP request to the application it is building. ## What to say out loud Name the test, then the default. "Does a caller I cannot redeploy have to reach this by URL, or is the response not a document? If yes, it earns an endpoint. If no, it stays a module the render calls directly, because an endpoint is a public contract and a network hop I would be paying for nothing." Then add the consequence that turns the rule into judgment: endpoints are easy to add and hard to remove, so the bias should run toward not adding one.
- The page needs more data after it is running in the browser. Does that force an endpoint?Something must be reachable by URL, because the browser cannot import a server module. But frameworks differ: where one offers a typed server-call mechanism that generates the transport, that is usually preferable, since it stays refactorable and is not a public contract in the same way. Where no such mechanism exists, an explicit endpoint is the honest answer.
- The same operation must be triggered by your own interface and by a partner's server. Which surface wins?Neither - the question is a false choice. Implement the rule once in a server-side module, then give it two thin edges: the route's own handling for your interface, and an endpoint for the partner. Picking one surface for both either pushes your own pages through a needless network hop or leaves the partner with nothing to call.
- Why is an endpoint harder to withdraw than an internal module?A module has known callers inside one repository, so changing it is a refactor the type checker supervises. A URL has unknown callers outside your deploy pipeline, so withdrawing it needs discovery, a deprecation window and coordination with people you may not reach. That asymmetry is why the bar for adding one should be higher than the effort of writing one.
saying these in an interview costs you the question
- Adds an endpoint for a read the route's server code already performs.
- Believes the app's own pages must fetch their data over HTTP.
- Treats an endpoint as internal because no interface links to it.
- Ignores that a published URL becomes a contract outsiders depend on.
- Thinks a browser fetch is cheaper than an in-process server call.