For a long time the SlashFeed API "documentation" was a single file: docs/api.md. It is 586 lines, it lives in the repo, and it is genuinely useful — for me. It is the source of truth I edit when I add an endpoint, and it renders fine on GitHub. But it has two problems for an actual user. It is not in the app, so nobody discovers it while they are logged in and looking at their token settings. And it is dead: a wall of prose and fenced code blocks that renders in whatever colours GitHub feels like, with no sense of "you are here."
So I built a second front-end for the same material: a Phoenix LiveView at /docs/api, styled like the Stripe API reference, that lives inside the app and adapts to the user's theme. This post is about why that is worth maintaining alongside the Markdown, and where I deliberately stopped short of building a full request client.
Two audiences, two artifacts
The static docs/api.md opens by listing all four API surfaces:
# SlashFeed REST & Fever API — IMPLEMENTED
1. **SlashFeed REST API v1** (`/api/v1/`) — native Stripe-inspired REST API
2. **Miniflux-compatible API** (`/v1/`) — drop-in Miniflux protocol
3. **Google Reader-compatible API** (`/reader/api/0/`) ...
4. **Fever API** (`POST /fever`) ...
That file is written for someone reading top to bottom, or grepping it. The LiveView is written for someone hunting a specific endpoint. Its structure is a navigation tree defined as a module attribute:
@nav [
{"Getting Started",
[
{"intro", "Introduction"},
{"authentication", "Authentication"},
{"rate-limiting", "Rate Limiting"},
{"errors", "Errors"}
]},
{"REST API v1",
[
{"me", "Account"},
{"feeds", "Feeds"},
...
Each {id, label} becomes a sidebar anchor, and the active item is tracked in socket state. mount/3 seeds it, and handle_params/3 updates it when the URL carries a section:
def handle_params(%{"section" => s}, _uri, socket),
do: {:noreply, assign(socket, active: s)}
That active assign is the whole reason this is a LiveView and not a static template: the sidebar highlights the current section with @active == id && "bg-primary/10 text-primary font-medium". It is a small thing, but "you are here" is exactly what the Markdown can never give you.
Everything follows the user's theme
The design note at the top of the module is emphatic about one decision, and I want to call it out because it is the opposite of what most API docs do:
There is NO forced
data-themeoverride.
Stripe-style docs almost always force a dark code panel. I use DaisyUI semantic tokens instead — bg-base-300, text-success, text-base-content/70 — so the whole page, code panels included, renders in whichever light or dark theme the user already picked for the app. The two-column layout is one small component:
defp row(assigns) do
~H"""
<section id={@id} class="grid grid-cols-1 lg:grid-cols-2 border-b border-base-300 scroll-mt-12">
<div class="px-8 xl:px-12 py-10 space-y-4 border-r border-base-300 bg-base-100">
{render_slot(@prose)}
</div>
<div class="px-8 py-10 space-y-5 bg-base-300">
{render_slot(@code)}
</div>
</section>
"""
end
Prose left, code right, one id for the anchor. Every section — intro, authentication, each resource — is a <.row>.
Code examples as compile-time strings
There is a subtle HEEx problem hiding in "render a JSON body." Curly braces are HEEx interpolation syntax, and JSON is nothing but curly braces. The module solves this by defining every example as a heredoc in one big @e map and rendering it as a single expression:
<pre class="...">{@e.auth_error}</pre>
Because {@e.auth_error} is one HEEx expression that evaluates to a string, any { or } inside that string is emitted verbatim and never re-parsed — no phx-no-curly-interpolation, and the indentation is exactly what I wrote in the attribute. It means the examples are plain data, checked at compile time, not fragile inline template markup.
Authentication, inline
The authentication section is the one place the in-app version clearly beats the file. It explains the sk_live_ prefix and the header inline, then shows both the happy path and the failure side by side in the code column:
curl https://slashfeed.app/api/v1/me \
-H "Authorization: Bearer sk_live_Rn8KdPwXmZq5"
...next to the 401 body from @e.auth_error. And the prose links straight to where you actually get the token — Settings → API Tokens via ~p"/users/settings" — plus a persistent "Get API Token →" link in the sticky header. On a static page that is a sentence saying "go to settings." In the app it is a click.
Where I stopped: Hoppscotch, not a built-in client
The obvious next step from "interactive docs" is a "Try it" button that fires the request from the browser. I did not build that. Minting a real sk_live_ token into client-side JS, handling CORS, and reimplementing a request runner is a lot of surface area for something two mature tools already do well.
Instead the docs hand off. Every part of the page — the sidebar, the Tools section, the footer — offers a downloadable collection:
<a href="/hoppscotch_collection.json" download="BoringRSS_Hoppscotch.json">
Hoppscotch Collection
</a>
That JSON is a static asset (priv/static/hoppscotch_collection.json), name "SlashFeed API", with folders for all four surfaces — REST API v1, Miniflux API, Google Reader API, Fever API. The docs even show the environment variables to paste after importing:
[
{ "key": "base_url", "value": "https://yourserver.com", "secret": false },
{ "key": "api_token", "value": "sk_live_your_token_here", "secret": true }
]
So the division of labour is clean: the LiveView is for reading and understanding, and Hoppscotch (or the Insomnia collection next to it) is for executing. The docs never touch a live token; the tool that does is one the user already trusts with credentials.
The maintenance cost, honestly
Two representations of one API is duplication, and I will not pretend otherwise. The @changelog attribute even carries a note enforced by AGENTS.md: every release that touches the public API must prepend an entry here and mirror it in docs/api.md. That is a real tax. I pay it because the two artifacts serve genuinely different readers, and because the LiveView gets one thing for free that a static file never will — it is served by the same app, in the same session, on the same TechArticle JSON-LD (SEO.api_docs_json_ld/0) that puts the reference in front of search engines. For a product whose whole pitch is "the API is a first-class surface," the docs being a first-class page, not a file in a folder, is the point.