Following Authors, Reading Anonymously: Bonding Before the First Login
Photo: Unsplash
The last two features before the internal alpha launch shared the same goal: readers should be able to bond with OutaStory before they've created an account. Nobody creates an account just to look around. The decision happens when the reader wants to start their second story — and by then we already need to have saved the bookmark and the favorite author. This week, two features that sound harmless at first glance but mean surprisingly much work under the hood.
Following authors — end to end
Every author page now has a Follow button. Click, count goes up, done. At least from the reader's point of view. Underneath runs a new entity AuthorFollow, an EF migration, a unique index constraint on (FollowerUserId, FollowedAuthorId), and a denormalized counter Author.FollowerCount that stays in sync on every follow or unfollow.
The reason for the counter on the author row: on an author page with a hundred followers, we'd otherwise run a COUNT(*) query over the entire follow table on every page view. Denormalizing avoids the query. The invariant is enforced at the repository layer: whoever calls FollowAsync gets the counter incremented; whoever calls UnfollowAsync gets it decremented. Failure modes are idempotent — following twice doesn't count twice, and unfollowing twice works even if you never followed at all.
Self-follow is rejected at the repository layer. An author can't follow themselves — that's intentional. The button on the author page is hidden when AuthState.CurrentUser?.Sub belongs to the author.
Follower graph in the seed: ~22 followers per author
An empty author page showing "0 followers" is a poor first impression. We built a deterministic follower graph into the seed pipeline: for every author/user pair, a SHA-256 hash is computed from (FollowerUserId | AuthorSlug). If the hash falls into the first 40-percent bucket, a follow relationship is created. That works out to ~22 followers per author across the entire seed pool.
The hash is deterministic, so every re-initialization produces the same graph. That makes testing easier — the numbers are reproducible. E2E test users are deliberately excluded, so our Playwright assertions don't depend on whether Auth0:SeedE2eTestUsers is set.
AverageRating per author — as a real aggregation
The author DTO now carries a new field, AverageRating. An unweighted average rating across all StoryRatings for all of that author's stories, computed server-side in a single GROUP BY projection. No second round trips, no N+1 trap.
That sounds trivial, but it has a side effect: we now have real, aggregated ratings from the very first call. The old value was a placeholder. The author page notices immediately.
Anonymous reading progress — in localStorage
The second big feature chunk of the week: reading progress for signed-out readers. Until now, "bookmark" implicitly meant "log in first." That doesn't fit with our founding decision that reading on OutaStory is always free and possible without an account. Anyone who clicks "Continue" twice and then closes the page should see the "Continue Reading" tile on the home page the next time they visit — whether logged in or not.
Photo: Unsplash
The implementation is an IAnonymousReadingProgressStore over JS interop into localStorage. A single JSON blob under the key os-anon-progress, read and written through the same paths as the server-side progress variant. Key invariants are preserved:
- Forward-only: a "page 1" event after page 5 has already been read resets nothing.
- Latched completion: once marked finished, it stays finished.
- Prerender-safe: if JS interop isn't available during the Blazor Server prerender (it throws
InvalidOperationException), the layer quietly falls back to "empty" without aborting a circuit.
Merging on first sign-in
The interesting part is what happens when the anonymous reader decides to create an account after all. Then the localStorage graph has to flow into the Auth0-bound server graph — without data loss, without duplicate entries.
That runs through a new endpoint, POST /api/users/me/reading-progress/merge, plus a repository method UpsertManyAsync, which applies the same forward-only and latch rules per entry as a live page change would. The client facade IReadingProgressService decides on every call whether to take the API path or the localStorage path; during the sign-in transition, it posts the entire local graph to the merge endpoint once and then clears localStorage.
Against concurrent sign-in events (auth-cookie refresh sometimes fires OnAuthChanged twice in quick succession), a SemaphoreSlim plus a _mergeAttempted flag provide protection — the merge runs at most once per circuit per sign-in.
The home page sees none of this
The trick we like most: the home page has no distinction between signed-in and anonymous readers. Same "Continue Reading" row, same "Read Again" tiles, same chip logic (amber for in-progress, green for finished). The service, hidden behind the IReadingProgressService interface, decides which data source it taps. The Razor component knows nothing about it — it just iterates over a list of StoryWithProgressDto rows and renders cards.
That's the kind of architecture decision that only pays off on the second feature. The reader simply reports progress to ReadingProgress.ReportAsync(storyId, percent, isCompleted). Whether that then ends up in localStorage or in SQL Server isn't its concern.
Small SEO follow-ups
On the side, we knocked out a few smaller SEO items that were still on the list before the alpha launch:
- The default OG image (the fallback image for pages without their own cover) is now 1200×630 — Facebook's recommended landscape size — instead of the old 16:9 stopgap.
- Category URLs with
?sort=…or?filter=…now set<meta name="robots" content="noindex,follow">. The canonical taxonomy URL remains the only indexed variant; Google and Bing continue to follow the links but no longer duplicate ranking signals across seven sort variants of the same page. - A few scaffolding components from the template start (
MainLayout,NavMenu) got removed. We no longer use them; the router layout isAppLayout.
What's next
After three weeks of building and a week of polishing, we're right on the doorstep of the alpha check-in. The Android app is waiting for closed-alpha approval from Google, iOS is still under review. Once both are through, invitations go out to the first external testers. And then comes the next layer of work: the things we'll learn from alpha feedback that we don't know yet.
