/projects
SentiNEO
Desktop-style tracker for NASA's near-Earth object feed and Astronomy Picture of the Day. A full rewrite on TanStack Start, React 19 and Tailwind v4, deployed as a Cloudflare Worker.
- Stack
- TanStack Start
- React 19
- TypeScript
- Tailwind v4
- three.js
- Cloudflare Workers
Case study
I rewrote SentiNEO from a single Next.js page into a desktop of nine draggable panels. New stack top to bottom, new place to run it, same two NASA feeds underneath.
What it replaced
The first version is still running at sentineo.vercel.app(opens in new tab). It is one Next.js route that lists the week's close approaches, styled with Geist UI, with the Astronomy Picture of the Day behind it. Data is fetched on the server, the rows are static, and nothing on the page moves.
Its limits were structural rather than visual. One page shows one thing, so there was nowhere to put an asteroid's orbit next to the feed it came from. The component library kept the UI work small and set the visual ceiling at the same time. And with nothing in the URL beyond the route, the only thing anyone could link to was the front page.
What I built it with
TanStack Start replaced Next.js, mostly for the router. Typed search params are a first-class thing there, and the whole panel system is search-param state. React Query handles the data with SSR hydration and a cache that persists, which suits an API that changes once a day and rate-limits hard.
React 19 with the React Compiler turned on, so there is no manual memoisation anywhere. Tailwind v4 with the theme as CSS variables, which replaced the component library with a set of tokens. Base UI for the four primitives worth not writing myself: dialog, popover, radio group, switch. three.js for the globe, loaded on demand so a 2.6 MB scene never lands on a first paint that does not need it. Kubb generates the clients, hooks and fakes from NASA's OpenAPI specs, so I regenerate that layer instead of maintaining it.
It runs as a Cloudflare Worker, with its own entry wrapping TanStack's stream handler so the security headers ship as real HTTP headers.
apod: entry('apod', 'apod', {
Icon: ApodIcon,
Body: ApodBody,
prefetch: prefetchApod,
accent: '#00ffff', // cyan, astronomy
}),How it is better
Nine panels instead of one page, and any number of them can be open together, each holding its own date and its own scroll position. Window position, focus and per-panel state all live in the search params, so an arrangement is just a link. No server session, no saved-layout ids, and the back button steps through arrangements. The asteroid panel can be open twice on two different objects.
Phones get their own shell rather than a squeezed desktop, with a flat radar in place of the WebGL globe. Self-hosting the fonts cut 1,421ms of render-blocking time from first contentful paint. Letting the server declare the viewport removed a layout shift that was costing a quarter of the performance score. Accessibility scores 100 on every route, checked on each deploy along with the tests, a bundle budget and a script that fails the build when a module imports across an architectural layer.