Why I moved from Astro to TanStack Start
Why TanStack Start fit my Cloudflare deployment and React client work, and what moving my portfolio from Astro involved.

Astro already supported React components, so I didn’t need to migrate just to use React. I moved this site to TanStack Start because it fit where I deploy and how I build applications for clients, even though most of the site is still writing and portfolio pages.
React frontend development is the primary service I offer, and I already use TanStack Router and Start on client projects. Next.js was another option, but Cloudflare is my primary deployment platform, and Start was a better fit for that setup. Using the same tools here gives me a familiar place to experiment with features I might later use in client work.
Astro suited the site I already had. Most public pages were prerendered, interactive components loaded where they were needed, and the framework handled a lot of the content and image work. I had already spent time making that setup lighter.
The next things I want to try are more dynamic marketing pages for my freelance services, connected to campaigns on Google Ads, LinkedIn Ads, X Ads, and other channels. I also want room to explore programmatic SEO, generating pages around relevant searches, and perhaps a client area for managing work and billing. These are directions I want to explore, not features the migration delivered.
Keeping them in the same application is part of the appeal. I want to develop the public site and those experiments together, using the stack I work in for clients. This is a small website without a large user base, so I have room to try things and change my mind.
I could have done this with Astro too. But the implementations I was considering felt patchy compared with working in one React application. Start gave me the development model I wanted, with tools I already knew and a deployment setup that suited me. That preference meant recovering behavior Astro had been handling for me.
I worked through the migration with Codex. We kept Git-backed MDX and moved the public site first, aiming to preserve its URLs, design, and interactions. The dashboards and any editorial interface could wait.
Find out what the old site actually does
The site already mixed Astro pages, Preact components, and Hono endpoints. Moving it meant converting the interactive components to React as well as replacing the page framework.
The useful inventory described behavior. What happens when someone follows an old confirmation link twice? Does Back close an image dialog? Can a reader play a video before the enhanced player loads?
Those questions also exposed bugs we didn’t want to preserve. The old Inquiry handler saved a message before sending its notification email. If the email failed, it returned an error even though the message was already stored. Retrying could create another row.
We had to decide which behaviors readers depended on and which ones needed to change. Matching the old site exactly would have kept that failure too.
Prove the difficult pages before converting the rest
We built a separate prototype around rich content, including an interactive Post and the My Next Filing case study. They exercised diagrams, charts, responsive images, dialogs, video, captions, and MDX imports. A minimal homepage wouldn’t have tested most of the things I was worried about.
The prototype also included a database operation and form failure cases, and ran through the Cloudflare Workers runtime.
It found problems early. An image imported only by server code ended up in the server build, while its public URL returned a 404. Generated diagrams made an unexpected Google Fonts request. A cold development start exposed a duplicate React runtime problem that warm-cache checks had missed.
These were integration failures in the proposed setup. Documentation for the individual packages couldn’t establish that our particular combination worked.
We kept the tested Start, Router, React, Vite, and Cloudflare versions together. Fuma Content became the collections and MDX compiler layer after the prototype checks. Responsive images used vite-imagetools with native picture and image elements. The application still owned publication rules, content identity, image policy, and emitted-file checks.
That division matters. Adopting a content library didn’t give us back every behavior Astro had supplied.
Keeping public pages prebuilt didn’t keep the islands
Most public content stayed prerendered. Contact and the Newsletter result page used request-time server rendering, so their initial HTML could reflect the relevant cookie or allowed result message.
We chose ordinary React hydration with targeted lazy loading. I accepted the additional JavaScript rather than making experimental hydration behavior a dependency of the migration.
The video player shows why that choice needed care. In the Astro version, a visibility directive delayed mounting the component. The component itself loaded the enhanced player when it mounted. A direct React conversion would have moved that work earlier, even if the component looked identical.
The React version had to own the delay. It also needed to preserve native MP4 playback if enhancement failed, and leave playback alone if the reader had already started the native player. Leaving the page had to clean it up.
Similar decisions applied to search and other heavy interactions. Diagrams remained build-time work.
Keeping that loading behavior now took application code where Astro had provided an island directive.
Content became more of my responsibility
Fuma Content handled collections and MDX compilation, but the application still needed rules for what could be published. Filtering a draft out of a listing wasn’t enough. Its text or images could already have reached the compiled output.
We excluded drafts before content imports and compilation, then tested the output for recognizable draft text and a draft-only image. We also kept the MDX conversion away from fenced examples that discussed Astro. A tutorial still needed to show the original syntax.
I used the migration to separate AI-assisted editorial work from release builds too. The previous pipeline could update descriptions, classifications, recommendations, and internal links while building the site. I want to review those decisions before publishing them.
Editorial preparation now updates inputs that I can review and commit. The build consumes them without asking a model to reinterpret the content. Regenerating an image placeholder doesn’t count as verifying its description, either. Computed metadata and editorial descriptions have separate freshness checks.
There was a less satisfying change to local authoring. Content edits needed to update the import list, page, topics, recommendations, and CSS together. Repeated in-process Vite restarts exhausted the heap during testing, so content changes now restart the development process and reload the browser. Rapid saves can still need a manual reload. That’s maintenance and friction I took on with the new setup.
Moving the API meant deciding what success means
The old site used Hono for its API. Moving to Start was an opportunity to consolidate routing, while keeping the database and email logic that didn’t depend on Astro.
We used native server routes for the retained HTTP endpoints and a server function for the new Inquiry form. The old contact endpoint was deliberately retired. That meant someone submitting from an already-open old page would need to reload.
A stored Inquiry now counts as accepted even if the notification email fails. A failed database insert still produces an error, but a notification failure is reported separately. That removes the misleading error that encouraged duplicate submissions. It doesn’t guarantee email delivery.
Subscriber transitions moved to conditional writes against the current status and token. Replaying a successful confirmation shouldn’t send another welcome email, and an unsubscribe link from an earlier subscription period shouldn’t cancel a renewed one.
The forms also needed a state for an unknown outcome. If the server saves a submission but the browser can’t read the response, the browser doesn’t know whether it succeeded. Automatically retrying can repeat an operation. We kept the entered values, explained the uncertainty, and allowed a deliberate retry.
Testing caught a case where a late response could redirect someone who had already chosen to leave. Typed calls connected the form and server, but we still had to decide what should happen when the reader moved on before the request finished.
Rollback needed the same care. The old and new Subscriber handlers could understand the same database columns while disagreeing about valid token transitions. Reverting the code could restore behavior that mishandled a later opt-out. Keeping an old build wasn’t enough to promise a safe rollback.
Persistent navigation changes who owns state
The React shell made state ownership more visible.
Search query state was supposed to survive ordinary navigation. An open portrait dialog wasn’t. Review found that opening the About portrait, going Back, and returning to About could reopen it without a click. A content image dialog could similarly return empty and take focus away from the page heading.
The shared fix released overlay ownership when its viewer left. It had to release only its own overlay, because Search or the menu might have opened in the meantime.
A screenshot wouldn’t have caught either problem. Preserving the design also meant testing what happened after navigation, including keyboard focus and whether a dialog stayed closed.
The new site still missed some performance targets
Before launch, four page metrics missed their targets. These are the medians of five Lighthouse runs per page, using the same mobile test settings against the local build we were considering for release. They describe that build, not current field performance or a controlled Astro-versus-Start comparison.
| Page | Metric | Median | Target |
|---|---|---|---|
| Blog listing | CLS | 0.196 | ≤ 0.1 |
| Newsletter Issue | CLS | 0.213 | ≤ 0.1 |
| Exam Post | LCP | 2.931 s | ≤ 2.5 s |
| Avatar Post | LCP | 3.280 s | ≤ 2.5 s |
Cumulative Layout Shift, or CLS, measures unexpected movement. Largest Contentful Paint, or LCP, measures when the largest visible content element appears. The LCP values are in seconds.
The causes weren’t interchangeable. On the Blog listing, the typewriter heading could shrink onto one line and pull the content below it upward by about 60 pixels. The Newsletter Issue shifted after its heading font loaded. The exam Post’s hero image accounted for its loading-time miss.
Across 45 Lighthouse runs, those were the four page-metric medians that missed their targets. All total blocking time medians passed. Other pages met their targets, including Home and My Next Filing.
I chose to launch with those problems and leave the fixes for follow-up. Readers still experience the movement or delayed image.
I also wouldn’t attribute every miss to React. The measurements identified page-level causes; they didn’t establish how much of each result came from changing frameworks.
Build performance needed its own investigation
Browser performance and build performance turned out to be separate problems.
The recorded cold production builds took roughly eleven and a half minutes. A warm build took about 41 seconds. Image rendition generation dominated the difference; Pagefind indexing took a fraction of a second.
We lowered AVIF encoder effort from 6 to 4 while keeping the quality setting unchanged. More usefully, we removed a broad cache invalidation rule.
The image tooling already hashed source bytes and transformation settings. Our outer cache key invalidated everything whenever the lockfile or media-build source changed. An unrelated edit could force thousands of renditions to regenerate.
We kept the existing cache engine and made encoder settings and image-tool versions part of the relevant rendition identity. Changing AVIF settings could invalidate AVIF without throwing away WebP. Renaming an unchanged image could reuse its bytes.
Keeping the quality setting unchanged doesn’t guarantee identical pixels, so this was still an encoding tradeoff.
After the effort and cache changes, a measured cold local build took 5 minutes 25 seconds. The warm build took 41.75 seconds and reused all 3,945 cached renditions without rewriting them. Those are observations from the changed setup, not an isolated benchmark of either change.
Deployment tested things the local build couldn’t
A local build couldn’t settle every deployment question. We checked redirects, search, and assets requested by already-open pages. We temporarily retained old hashed assets and tested an old Astro tab searching and then navigating into the new site.
Video delivery exposed another gap. Range requests returned the entire asset with HTTP 200, instead of the requested bytes with 206. We’d observed that locally, and the live check confirmed it.
The follow-up fix streamed the requested range through the Worker, using generated video sizes where the asset binding omitted a content length. Live byte checks and a throttled seek then passed.
The fix had a limit. The browser received only the requested range, but the Worker still read past preceding bytes internally. It improved client transfer and seeking without providing storage-level random access.
Some checks remained incomplete. WebKit automation didn’t establish behavior on a real iPhone. Apple-device review was deferred, and the deployment checks didn’t send live email.
Now I have to use it
The public site runs on TanStack Start, and I have the React application I wanted to maintain. Getting there meant rebuilding content rules, revisiting form failures, and checking interactions that had looked unchanged. Some of that improved the site immediately. Some of it was work I wouldn’t have needed if I’d stayed with Astro.
The marketing pages and client workflows still need to be built. The next feature has to justify that choice in practice.


