Introduction:
Since Feedly's redesign, I haven't been able to get used to the interface. I especially miss the high-density, single-column, no-nonsense reading rhythm of the old Feedly Classic. My subscription sources include many English, Japanese, and Korean technical blogs. Every time I encountered them, I either had to open a new tab for translation or simply skip them. Over time, these sources became "read but ignored."
Instead of continuing to settle, I spent a weekend building my own RSS reader: FeedFlow. It features a mobile-first design, dark theme, and multi-column view modes—a tribute to Feedly Classic. Data is stored in Google Firestore, and accounts are tied to LINE Login. For non-Chinese articles, the background automatically uses Gemini 2.5 Flash to translate them into Traditional Chinese. It is currently deployed on Cloud Run, and I use it every day.
This repo will continue to be developed. This post is the first in a series, documenting the skeleton of the entire project and the context behind several key decisions.
TL;DR
This article will introduce:
- Why build an RSS reader from scratch
- The significance of LINE Login in this project
- The RSS reader development process: From standalone MVP to multi-user cloud sync
- Why Gemini was chosen as the translation engine and the pitfalls encountered
- Frontend interface: Four view modes and mobile-first design
- An unexpected interlude: Cleaning leaked keys from git history
- Current progress and the direction of the next post
- Conclusion
- Reference links
Why build an RSS reader from scratch
There is no shortage of RSS readers on the market, but what I wanted was very specific: it needs to be fast on mobile, have high information density (no big images taking up the whole screen for every article), and allow for in-place translation of foreign sources without switching tabs. These three conditions combined are not fully met by any existing service, especially "automatic translation of non-Chinese articles," which is almost never treated as a first-class citizen in other readers.
The project is named FeedFlow, and its core consists of three parts: an Express backend (server.js) responsible for fetching RSS, parsing content, calling Gemini, and reading/writing to Firestore; a frontend using pure Vanilla JS ES Modules (app.js, store.js, api.js, i18n.js) without any frameworks; and Firestore for the database with LINE Login for accounts. The first MVP version produced subscription management, folder categorization, four view modes, and a dark theme. Every subsequent version has been built upon this skeleton.
The significance of LINE Login in this project
I previously wrote about How to develop OAuth2 PKCE via Golang, which discussed the implementation details of introducing PKCE to LINE Login. At that time, LINE Login was the subject of research for deconstructing the protocol. In FeedFlow, LINE Login takes on a different role—it is the key to making the multi-user architecture viable.
FeedFlow initially didn't have an account system; data was stored in the browser's localStorage. If you switched devices, your subscriptions would disappear. To achieve cloud sync, the first step was to have a stable user identity that could serve as a key for document paths in Firestore (users/{userId}/...). Instead of building a custom username/password system, using the LINE User ID (sub claim) obtained via LINE Login as this key saved me from building the entire password, verification email, and "forgot password" flow. For a personal project, this was much more cost-effective.
This decision wasn't made all at once. The earliest version was a "lazy" version: letting users paste a LINE UID string, which the backend just used as a Firestore key without any authentication. This kind of "login" meant anyone could impersonate any UID. The next version switched to formal LINE OpenID Connect: a standard OAuth 2.1 authorization code flow. After obtaining the id_token, the signature is verified using LINE's /oauth2/v2.1/verify, and the session is stored in an HTTP-only cookie rather than being passed around in URL parameters. Later, state and nonce checks were added to block CSRF. This "get it working first, then get it right" sequence reflects the typical rhythm of personal projects: get the features working to confirm the direction, and fix security holes once the need is visible.
Since the primary use case is sharing links within LINE and opening them in the LINE in-app browser, I also integrated the LIFF SDK. This allows users opening the app within LINE to log in directly using LIFF's SSO without being redirected to an external browser.
The RSS reader development process: From standalone MVP to multi-user cloud sync
The features during the MVP stage weren't much different from a standard RSS reader: paste a URL, the backend parses the feed using rss-parser, and if no feed is found, it uses cheerio to scan the webpage's <link> tags for auto-discovery. Subscribed sources can be categorized into folders; articles can be marked as read or "mark all as read"; and a refresh function pulls the latest articles. In this version, all data was still stored in localStorage.
What truly made the project "feel like a product" were the versions after connecting to Firestore. Each user's subscription list, folders, reading progress, and preferences are written to independent paths under users/{LINE_UID}/..., ensuring multi-tenant data isolation. Reading progress is tracked in detail: not just a list of "read article" IDs (readArticleIds), but also which article was last read for each feed (lastReadArticleId), allowing users to pick up where they left off across devices.
An interesting small mechanism is "auto-hydration": when Cloud Run redeploys or a user logs in on a new device, there is no article data in memory, only the subscription list in Firestore. At this point, the backend re-fetches and parses each feed in the subscription list in the background to populate the screen. Users don't see an empty "please subscribe first" screen; the transition is quite natural.
Later, a "rich preview" version was added: when an RSS URL is pasted, in addition to fetching the feed title and description, it also grabs the latest three articles as samples. Non-Chinese content is sent to Gemini for translation, so users can understand what the source is about before actually subscribing.
Why Gemini was chosen as the translation engine and the pitfalls encountered
Translation was necessary because the subscription list contains many non-Chinese sources. The logic is simple: the backend detects the article language; if it's not Traditional Chinese, it's sent to Gemini 2.5 Flash, which returns a JSON structure with translatedTitle / translatedContent. The frontend adds a "✨ Trad-Ch" translation badge to article cards and the reader, and the reader includes a button to toggle between the original text and the translation. Choosing Gemini was straightforward: the latency and cost of 2.5 Flash are suitable for this "translate several articles upon entering the screen" usage. Other APIs could do it, but I was already using this GCP project, so it was easy to integrate.
The first version was the most direct: using GEMINI_API_KEY to call the Generative Language API. It worked, but after deploying to Cloud Run, it meant managing an extra set of API Key environment variables, increasing the risk of key leakage. Later, I changed the translation part to use Vertex AI, authenticating with the Cloud Run service's own identity (ADC, Application Default Credentials). This eliminated the need for a separate API Key—the Cloud Run service account itself has permission to call Vertex AI; you just need to set the IAM permissions. When the ADC environment is unavailable during local development, it falls back to GEMINI_API_KEY.
After this version went live, a whole batch of translation requests failed in the Cloud Run logs with the message: "Neither Vertex AI ADC self-identity nor GEMINI_API_KEY is available," even though both paths were configured. Upon investigation, I found I had misused the @google/genai SDK: it requires a boolean vertexai: true along with project and location as parallel parameters. I had initially written it as a nested vertexai: { project, location }. The SDK's logic for "is Vertex mode on" and "did it read project/location" were separate; consequently, the SDK thought Vertex mode was on but couldn't find the project or region, causing every call to fail at the start.
Instead of wrestling with the SDK's parameter rules, I eventually removed the @google/genai package entirely. I used google-auth-library's GoogleAuth to directly request an ADC token and constructed the HTTP request to the Vertex AI generateContent REST endpoint myself. With one less layer of SDK abstraction, the behavior became much more predictable. In cases like this, bypassing the SDK and hitting the REST API directly is often easier than digging through documentation to find which parameters should be nested or parallel.
Frontend interface: Four view modes and mobile-first design
The reference point for the interface design was Feedly Classic: a view mode toggle button in the top right, with four modes applying different layouts to the same article data without re-fetching.
| Mode | Features |
|---|---|
| Magazine | Default mode, summary cards with images and text, suitable for quickly scanning titles and snippets. |
| List | High-density text-only list, showing the maximum number of articles at once. |
| Title Only | Only titles are kept, allowing for the fastest scrolling speed. |
| Cards | Visual cards focused on large images, suitable for sources with rich visual content. |
The sidebar uses a folder structure, listing each folder and uncategorized subscriptions with unread count badges. The article list and the reader are two separate panels; clicking an article slides it from the list into the reader, and a swipe-up gesture slides it back to the list. The operation logic follows mobile native app habits rather than the typical web behavior of full-page jumps. The overall theme is dark. The top bar contains icons for the menu, view modes, mark all as read, refresh, and settings. After logging in, the LINE display name appears where the "LINE Login" button used to be.
In the settings page, "Interface Language" and "Translation Target Language" are split into two independent options. The interface currently supports zh-TW / en / ja. The translation target language determines which language Gemini translates foreign text into. There's no reason to tie these together; a user might want an English interface but still want to translate Japanese articles into Traditional Chinese.
Current progress and the direction of the next post
As of writing this, two new documents have been added to the repo (docs/superpowers/specs/ and docs/superpowers/plans/) to plan for paginated browsing of the article list—five articles per page, supporting swipe gestures, mouse wheels, and buttons for navigation. This part is still under development and will be the subject of the next post in this series.
Conclusion
FeedFlow currently solves a simple problem: quickly scanning a bunch of foreign technical articles on mobile without switching tabs for translation or enduring a bloated interface. Three decisions support this architecture: LINE Login provides user identity without a custom account system, Firestore enables multi-user cloud sync, and Gemini 2.5 Flash (via Vertex AI ADC, without storing API Keys locally) handles translation. None of these were perfect on the first try—LINE Login evolved from string pasting to formal OAuth, and translation moved from raw API Keys to service identity authentication. It was all about getting it running first and then strengthening it.


Top comments (0)