Pagination, Share Previews, Continue Reading: The Final Push Before Closed Alpha

Pagination, Share Previews, Continue Reading: The Final Push Before Closed Alpha

paginierunginfinite-scrollopen-graphskiasharpweiterlesenandroidalphaseo
Fiction

Pagination, Share Previews, Continue Reading: The Final Push Before Closed Alpha

Endless library shelves full of books Photo: Unsplash

Between the external alpha invitations and the database schema, there's a gap of features you only need once real people are actually looking at the site. Three days, four chunks. Each one a classic "that's easy, surely" sentence that we turned into a "this has a double bottom" project.

Server-side category pagination with infinite scroll

Until now, the category page loaded the entire catalog for the current language in one go and filtered client-side. At 750 stories per language that's still fine; at 5,000 it gets uncomfortable; at 50,000 it's broken.

The redesign:

  • Routes: /category, /category/{L0}, /category/{L0}/{L1}, /category/{L0}/{L1}/{L2}, plus a …/page/{n} variant for each. Eight route templates on a single Razor component, deep-linkable at every level.
  • API: GET /api/stories/by-category/{slug}?page=1&pageSize=24&sort=new returns a PagedResult<StoryDto> with items, page, page size, and total count.
  • Repository: the slug is resolved once into a flat list of category IDs (self plus descendants), then EF filters over CategoryId IN (...) instead of the four-level ParentCategory walk the old variant needed.
  • Composite indexes: three new indexes on Stories — one each for the newest, top-rated, and trending sort variants. Pagination on page 40 is now O(log n) instead of a table scan.
  • Stable sorting: every sort ends with .ThenByDescending(s => s.Id) as a tiebreaker. Without it, two stories with the same PublishedDate would drift or disappear between consecutive pages.

The sort chips (Default / Trending / New / Top Rated / Complete) now live server-side. Clicking a chip resets pagination to page 1 — the ordering changes, so the old scroll position is meaningless.

Infinite scroll, but honest

For scrolling itself, there was a fundamental decision to make: URL-path-based (/page/{n}) or query string (?page=n). Since 2025, Google has tended to recommend query strings because Search Console attribution is cleaner that way. But: you wanted paths, and that has another advantage — every page is its own SSR resource, rel=next/rel=prev can be served cleanly, and crawlers follow the link graph without executing JavaScript.

The infinite-scroll behavior lives in a new InfiniteScroll.razor component:

  • A 1px sentinel sits at the bottom of the page, with an IntersectionObserver attached to it. When the sentinel enters the viewport, OnLoadMore fires and the next page gets loaded in.
  • Below the sentinel there's always a visible "Load more" button. For three reasons: accessibility (keyboard users never reach the sentinel), SEO (crawlers follow the <a href="?page=2">), and fallback (if the SignalR circuit hangs, the reader can just click).
  • On append, the URL gets rewritten to the newly loaded page via history.replaceState. That leaves Blazor's router alone while still updating the browser address and the sharing link.

A deep link to /page/3 loads only page 3, not 1-2-3. Above it, a "Show previous pages" link appears that leads back to /page/1. Same approach as Medium or Dev.to — no wasted payload, every URL is independently indexable.

And yes, we picked up and documented two ugly Blazor bugs along the way:

  • In OnAfterRenderAsync, a race can occur between two consecutive renders: both see _observerAttached=false, both try to attach. The fix was to set the flag before the await, not after.
  • DotNetObjectReference must not be disposed in the detach path when the ref gets reused between renders — the silently invalid ref caused the IntersectionObserver callback to quietly fizzle out. There are now two separate functions: detach (observer only) and teardown (observer + ref, only in the dispose path).

Share previews: 1200×630 per story

Until now, every story detail page got an og:image, namely the portrait cover (3:4). That looks terrible on Facebook and LinkedIn — the platforms stretch or crop it, no text sits in the right spot, and the preview loses its context.

The solution: a dedicated 1200×630 landscape variant per story, composed server-side.

Open book on a table Photo: Unsplash

The new path:

  • An OgImageGenerationMessage flies into the Service Bus queue ogimageprocessor on every publish action. A sibling of the cover queue, in the same Azure Functions project (OutaStory.ImageProcessor).
  • A function uses SkiaSharp to compose the real cover (background gradient burned in together with the cover image on the left) with the title (Inter Bold 52px, max 3 lines) and author on the right. Bottom right carries an "OutaStory" wordmark.
  • The blob name is content-addressed: story-{id}-{sha256[:16]}.jpg. Same inputs → same name → safe Cache-Control: public, max-age=604800, immutable headers, because the URL never changes under the same content.
  • If the cover or title changes, the hash changes. New blob, new URL, the old one gets deleted. No ghosts.

We would have preferred ImageSharp, but its license has required payment for commercial use above $1M revenue since mid-2022. SkiaSharp is free, runs in Linux containers with the NativeAssets.Linux.NoDependencies package (statically linked, no libfontconfig dependencies), and renders text cleanly thanks to HarfBuzz integration. The Inter fonts are licensed under SIL OFL — we commit them into the repo, so the Azure Functions container brings them along, with no apt-get in the deployment pipeline.

The reader fallback: if the cover file isn't reachable for OG generation, the compositor falls back to a pure text layout (gradient + title + author) instead of letting the pipeline fail hard. A Service Bus blip shouldn't leave the OG page empty.

On the side: og:image:alt and twitter:image:alt are now automatically generated as "Cover art for '{title}' by {author}" when the page doesn't explicitly override it. Twitter/X reads this out to screen readers, LinkedIn uses it as a small ranking signal. Costs nothing, helps a bit.

"Continue Reading" on the detail page

The story detail page previously had a single "Start Reading" button that always landed on /read/1. Doesn't matter if the reader is already halfway through — page 1, hello, off you go.

Now the button is context-dependent:

  • Untouched → "Start Reading" → /read/1
  • In progress (45%) → "Continue reading • 45%" → /read/{LastReadPage} + a line underneath: "Picks up on page 3."
  • Finished → "Finished ✓ — Read again" → /read/1 (restart)

For the exact page, we needed a new field in the DB: StoryReadingProgress.LastReadPage. Unlike PercentComplete, this one isn't forward-only — someone who flips back from page 7 to page 3 gets 3 written in. Reader intuition is "take me back to where I last was," not "to wherever I got furthest." Percent stays a forward-only progress measure, page is the last place you were.

The feature works identically for signed-in and anonymous readers. The ReadingProgressService facade decides behind the scenes whether it lands on the API or on localStorage — the detail page notices none of it.

Android: closed alpha approved

And finally, after countless pipeline iterations: the Android app has been approved by Google Play for closed testing on the alpha track. The CI pipeline previously stalled on a misleading API error ("Only releases with status draft may be created on draft app"), which in reality meant: "Your app listing is still in draft state, you're not even allowed to upload to alpha, internal is the only channel that works."

The fix was threefold:

  1. The deprecated track: input on our upload action was silently overridden by a new tracks: default (production). We switched to the plural form.
  2. Play Console → Store Listing + App Content fully green, Privacy Policy, Content Rating, Data Safety — all filled out.
  3. The track is now configurable via an ANDROID_PLAY_TRACK repo variable (default internal). Going from internal to alpha to production is now just a variable flip, no YAML change.

The iOS app is going through App Review for TestFlight Closed in parallel. Once both are ready, the alpha invitations go out.

What's next

Closed alpha means: real people, real feedback, real numbers. Everything so far on my machine has tests, but tests can't simulate what happens when someone opens the app for the first time and spends the next two minutes hunting for the wrong button. The next few weeks are about pulling the next priorities out of the alpha feedback — and then reshuffling the roadmap.


Comments (0)

No comments yet.


Leave a comment

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.