I waited for the App Router to become stable

Next.js 13.4 marked the core App Router stable, so K81R began a bounded adoption while keeping URLs, authority, and rollback explicit.

I opened the App Router experiment again on the day Next.js 13.4 marked its core stable.

The beta branch from October 2022 had taught me enough to be interested and not enough to migrate P6X4. Nested layouts, Server Components, streaming, and server-first data access fit the direction of the work. The interfaces and guidance were still moving, and the existing routes were dependable.

Stability changed the decision from “Is this worth watching?” to “Which bounded route would justify adoption?”

It did not change the decision to “Rewrite everything.”

Waiting had produced a better question

The beta experiment asked whether the model was compelling. The 2023 adoption asked whether it improved one real personal-project workflow under failure, accessibility, deployment, and maintenance constraints.

K81R's source document route was the candidate. It combined:

  • Disclosure-safe authorization.
  • An immutable source revision.
  • Server-rendered document structure.
  • A client interaction for selecting evidence.
  • Nested navigation across document, history, and relationships.
  • Optional search context that could stream later.

The route had enough complexity to reveal architectural value and no external effect that could be duplicated by a new rendering path.

I left generation, evidence-set mutation, and account-like preferences on their established routes until the read path proved itself.

The release distinguished the core App Router from newer adjacent capabilities that retained earlier maturity labels. I recorded the status of every API used instead of inheriting one label from the framework version.

The adoption scope included:

  • app directory routing.
  • Nested layouts and route segments.
  • React Server Components in the supported framework model.
  • Loading and error boundaries.
  • Streaming server rendering.
  • The documented data-fetching and metadata paths required by the route.

It excluded experimental mutation features and convenience APIs not needed for the source viewer. A stable router could host an established HTTP command protocol later; it did not require adopting every new server-side interaction mechanism at once.

Maturity was a property of the actual dependency surface.

The old route became the acceptance oracle

I captured the existing route's observable contract:

  • Public URL and canonical metadata.
  • Disclosure behavior for missing and inaccessible sources.
  • Heading structure and source content.
  • Revision and relationship labels.
  • Keyboard navigation and focus after route changes.
  • Evidence-selection behavior.
  • Error and stale-index states.
  • Response headers and cache policy.

The new route could improve performance and composition. It could not silently change these guarantees.

Fixtures rendered both implementations against the same immutable source revisions and authority decisions. Differences were classified as intended, framework representation, regression, or unknown. The comparison prevented excitement about server components from obscuring a broken source link.

URLs moved before code only on paper

The public source URL already existed under the Pages Router. I did not publish a second app URL and plan redirects later.

During development, an internal prefix hosted the new route. Cutover used one routing decision to place the established URL on the App Router implementation. The old implementation remained available through the comparison harness during a bounded rollback window, not through a competing public address.

Canonical links, RSS references, saved evidence-set source URLs, and browser history all continued to name the same source and revision.

Framework adoption should not make content identity a migration casualty.

The server component read through a domain API

It was tempting to query K81R's database directly inside the route component. Server execution made that easy and would have coupled rendering to persistence.

The route called a supported domain query:

getSourceView({
  sourceId,
  revision,
  decisionContext,
})

The result contained disclosure-safe identity, authored structure, relationships, revision status, and evidence-selection references. It did not expose database rows or unrestricted repository access.

The same query served the existing route and scenario tests. Framework code decided how to render and stream it; the domain boundary decided what the requester could know.

Server Components moved data access closer to rendering. They did not remove the need for a data-access architecture.

The source body, headings, revision metadata, and relationship list required no browser state. They remained server components. Evidence selection, copy interaction, and local filtering needed client behavior.

I placed client boundaries around those interactions rather than around the whole page. Props crossed the boundary as serializable, bounded values: source passage identity, selected state, label, and command endpoint. The client did not receive the entire document object because one button needed an ID.

I inspected the client bundle and dependency graph. A syntax-highlighting convenience component pulled a large library into the client subtree; moving highlighting to server-rendered markup kept the interactive code small.

The boundary was not a purity contest. It was a decision about which code and data the browser needed to own.

The document layout loaded disclosure-safe source identity and rendered navigation for content, history, and relationships. Child routes reused that frame.

This reduced repeated wrappers and preserved a stable reading context during navigation. It also forced me to define what could stay stable. If authorization changed or the requested revision did not belong to the document, the layout could not preserve an old title around an invalid child.

The domain query returned an authority and source revision token. Child views verified compatibility. Error boundaries followed the same scope: optional relationship failure remained local; loss of source authority invalidated the document frame.

Layout persistence was useful only while its identity claim remained true.

Loading boundaries followed authored structure

The stable source document was available quickly. Relationship analysis and “found through this query” context could take longer.

I streamed the document frame and source content together. The relationship region arrived later under its existing heading. Query context was optional and could fail without weakening the source.

I did not put a loading file at every segment by default. A broad segment fallback could replace useful parent content during a small child transition. Component-level Suspense handled narrower, independently meaningful regions.

The lessons from P6X4's spinner-heavy page transferred directly: framework conventions offer boundaries; the product still decides their meaning.

Metadata waited for authorization

Document titles could reveal the existence of restricted sources. Dynamic metadata generation used the same disclosure-safe source query as the page.

Missing and inaccessible sources returned generic absence metadata. The canonical URL included only authorized source and revision identity. Descriptions used bounded public summaries rather than arbitrary first-paragraph content that could contain sensitive detail.

I captured head output in route fixtures because body tests would not reveal a leak in a title or social metadata.

Moving metadata generation server-side made data access convenient. The order—authorize, then describe—remained non-negotiable.

Cache behavior was specified per query

K81R held immutable source revisions, current document status, and an active index generation. Their freshness needs differed.

An immutable source revision could be cached by its stable content identity. Current status and relationships needed invalidation when the corpus changed. Authorization decisions required current policy and could not be shared across request scopes casually. Query context depended on the active index generation.

I documented source, authority, cache key, invalidation, and acceptable staleness for every server read. The route integration remained thin enough to adapt as framework behavior evolved.

The interface displayed relevant freshness: “Indexed through corpus revision 31,” not internal cache vocabulary.

Stable routing did not make cache semantics universal.

Errors described what remained trustworthy

The scenario suite forced:

  • Malformed source and revision identifiers.
  • Missing source.
  • Inaccessible source.
  • Corpus database failure.
  • Relationship projection failure.
  • Active index generation unavailable.
  • Client selection component failing to load.

Missing and inaccessible shared the same public absence. Database failure replaced the authoritative source region with a bounded error and stable request identity. Relationship failure stayed local. The source remained readable if optional client selection failed; an ordinary link could still navigate and copy remained a browser capability.

The error boundary's location expressed recovery scope, not component ownership.

Progressive enhancement protected reading

With JavaScript delayed, the source route remained a complete document. Headings, code, tables, revision status, and source links were present. Evidence selection used a conventional form action as a baseline in the established command endpoint; client code enhanced it with local selection and a persistent tray.

I did not move the acceptance protocol into an experimental feature for elegance. Stable intent ID, expected evidence-set revision, and selected passage identity crossed the form boundary exactly as before.

This made the migration resilient to hydration delays and kept generated client code from owning the only path to the core content.

Final screenshots could not reveal stale layout data or focus loss. I scripted navigation:

  1. Open a source revision from search.
  2. Select a passage.
  3. Navigate to revision history.
  4. Return with browser Back.
  5. Open a related document.
  6. Lose access between navigations.
  7. Trigger optional relationship failure.

The tests asserted URL identity, heading continuity, focus, preserved or reset selection according to source identity, correct absence behavior, and no disclosure from a previously rendered layout.

Server-first navigation changed the machinery. The responsibility for a coherent history remained.

The route streamed correctly in development. I tested it through P6X4's local reverse proxy, compression, caching headers, and production build output. Buffering could erase the benefit; an incorrect static classification could retain private content; missing runtime configuration could fail only after deployment.

The build report, initial HTML, response sequence, and client readiness became verification artifacts. A framework abstraction was not “working” until the complete path honored it.

I also tested a mixed deployment window in which old and new application instances overlapped. Both understood the same source URLs and domain contract. No durable work depended on which renderer answered.

Incremental migration had a ledger

Each route candidate recorded:

  • Why the App Router model improved it.
  • Domain reads and client boundaries.
  • Loading, error, and metadata contracts.
  • Comparison fixture status.
  • Bundle and performance result.
  • Rollback window.
  • Old-route deletion conditions.

Routes that gained no meaningful benefit stayed where they were. A simple confirmation page did not become more maintainable merely by moving directories.

The ledger prevented file migration count from becoming progress. Adoption was measured in retired old paths and improved route contracts.

Shared components needed rendering-neutral cores

Several design primitives assumed client execution because the old application had placed React broadly in the browser. Buttons and fields with interaction remained client-capable. Typography, layout, status labels, and source rendering did not need client state.

I split primitives carefully without duplicating their visual contract. Server-safe components imported no browser-only dependency. Interactive wrappers composed them where necessary. Accessibility attributes remained part of the shared API.

This work improved the design system beyond the migration. It also showed that a framework boundary can expose responsibilities previously blurred in reusable components.

Fear of future change tempted me to build a custom router facade, cache facade, server-component data wrapper, and universal page abstraction.

I kept stable domain queries and UI primitives independent. The route itself used the framework's documented conventions directly. Thin adapters handled truly volatile integration points such as active corpus invalidation.

Wrapping every feature would have made the migration harder to understand and could preserve a mistaken model longer than the framework API.

Abstraction was earned where K81R owned the concept, not where I felt anxious about a dependency.

The cutover was intentionally boring

The source route passed comparison, accessibility, failure, build, and temporal-navigation checks. I switched the established URL to the App Router implementation for the local assembled site, retained the old route in the harness for the rollback window, and watched the same synthetic scenarios.

No content IDs changed. No evidence set broke. The client bundle decreased. The document frame arrived earlier than optional relationships. Reading worked without JavaScript. The authority and command protocols remained unchanged.

After the window, I removed the old route, its tests, data adapter, and build assumptions. A migration was complete only when the duplicate implementation disappeared.

Waiting had costs. I carried old route composition longer and learned the new model later than an early adopter would. The beta experiment kept that gap small without turning it into site-wide risk.

When the core App Router became stable, I had a dated list of open questions, a representative fixture, and clear adoption gates. The decision could move quickly because the preparation had not become a premature abstraction.

The stable release did not make every route better. It made a supported adoption possible. K81R used that possibility where server-first source rendering, nested identity, and progressive evidence genuinely fit.

I waited for stability and still required the route to earn the move.

The result stayed deliberately incremental.

That combination—patience before adoption and rigor after it—kept framework enthusiasm from becoming another source of invisible migration debt in the project.

Search engines and machine readers saw the same document

The server-rendered source route also fed K81R's public metadata, sitemap-like inventory, and internal machine-readable exports. I verified that the initial HTML contained the authored source and heading structure rather than depending on client hydration.

Structured metadata referred to the immutable source revision and disclosure-safe description. Alternate representations used the same canonical identity. An unavailable optional relationship region did not remove the source from the document or make its metadata contradict the body.

This mattered beyond discoverability. A server-first route should provide one coherent document to browsers, assistive technology, and later ingestion processes—not several truths divided by execution context.

Server Components moved some failures out of browser telemetry and into server rendering. I added route-segment, source ID, source revision, corpus generation, release ID, and request identity to structured diagnostics, with sensitive values excluded or hashed according to policy.

The operational view distinguished authority query time, rendering time, streamed optional-region time, and client readiness. A slow relationship projection did not make the source query look slow. A cancelled navigation did not become an unexplained server error.

Logs remained evidence for the domain state. They did not replace source and evidence-set records.

Route prefetching respected access and cost

Automatic navigation preparation could request routes a person never opened. For public static-like content that was often useful. For restricted sources and expensive relationship analysis, eager work could create unnecessary authorization checks, logs, and computation.

I tested the framework's navigation behavior on source links and chose prefetch policy according to route sensitivity and cost. Prefetched responses never bypassed current authorization at actual navigation. Optional analysis did not run merely because a link became visible unless the benefit was demonstrated.

This was another example of an implementation convenience becoming a product data-flow decision.

During incremental adoption, pages and app routes could emit different navigation events and timing milestones. A naive dashboard would show an apparent behavior change caused by instrumentation.

I defined route-view identity, source readiness, client readiness, and navigation completion in a framework-neutral analytics contract. Each router adapter implemented the same bounded observational events. Duplicate server and client observations shared a stable navigation identity.

The personal lab used synthetic scenarios and short retention, but the contract still prevented adoption from corrupting comparison data.

Dependency boundaries were checked in both directions

The new route could import server-only repositories safely from its composition layer. Shared components used by old client routes could not begin importing those server modules through a barrel export.

Architecture tests marked server-only packages and failed if a client boundary or Pages Router browser bundle reached them. Client components declared their boundary explicitly and exposed serializable props. The build output was inspected for unexpected database, parser, or model dependencies in browser chunks.

This protected the old and new worlds during coexistence. Server capability did not leak through a convenient shared index file.