<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Articles - Roberto Marçal</title><link>https://cmarcal-roberto.dev/articles/</link><description>Frontend engineer building digital products at scale, with depth in architecture, performance and quality, and leading the adoption of AI in software development.</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sat, 10 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://cmarcal-roberto.dev/articles/index.xml" rel="self" type="application/rss+xml"/><item><title>Next.js vs TanStack Start on plain AWS</title><link>https://cmarcal-roberto.dev/articles/nextjs-vs-tanstack-start-on-aws/</link><pubDate>Sat, 10 Oct 2026 00:00:00 +0000</pubDate><guid>https://cmarcal-roberto.dev/articles/nextjs-vs-tanstack-start-on-aws/</guid><description>What actually changes when the same SSR app lands on the same AWS setup.</description><content:encoded>&lt;p&gt;This is the text version of an interactive article. Read it on the site: &lt;a href="https://cmarcal-roberto.dev/articles/nextjs-vs-tanstack-start-on-aws/"&gt;https://cmarcal-roberto.dev/articles/nextjs-vs-tanstack-start-on-aws/&lt;/a&gt;&lt;/p&gt;&lt;div class="ax"&gt;
&lt;p class="meta"&gt;S3, CloudFront, Route 53 and Lambda, no Vercel&lt;/p&gt;
&lt;p class="sub"&gt;What actually changes when the same SSR app lands on the same AWS setup.&lt;/p&gt;
&lt;p class="lead"&gt;Put either framework behind CloudFront and, from the outside, it looks the same. The differences are in the details: where your code runs, how data gets changed, how fast new content goes live, and how much you have to maintain when something breaks.&lt;/p&gt;
&lt;div class="tldr"&gt;
&lt;div class="card next"&gt;&lt;h3&gt;Next.js via OpenNext&lt;/h3&gt;&lt;p&gt;Does more out of the box (ISR, image optimization, middleware), but you need more AWS pieces and a community adapter to get there.&lt;/p&gt;&lt;/div&gt;
&lt;div class="card tan"&gt;&lt;h3&gt;TanStack Start via Nitro&lt;/h3&gt;&lt;p&gt;Fewer AWS pieces and clearer server/client boundaries. In return you get fewer built-in features, no official AWS target, and it was still a release candidate as of November 2025.&lt;/p&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p class="note"&gt;Everything here links to a source. The boxes marked Opinion or Inference are my own reading.&lt;/p&gt;
&lt;ol class="toc" style="list-style:none"&gt;&lt;li&gt;&lt;a href="#flow"&gt;1. From merged PR to a page in the browser&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#adds"&gt;2. What each framework adds&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#code"&gt;3. Where your code runs&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#fresh"&gt;4. How content gets fresh&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#routes"&gt;5. Rendering strategy per route&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#cwv"&gt;6. Core Web Vitals&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#ops"&gt;7. What you own when it breaks&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="#lambda"&gt;8. Going deeper: Lambda and mutations&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;
&lt;h2 id="flow"&gt;1. From merged PR to a page in the browser&lt;/h2&gt;&lt;p&gt;Before comparing the frameworks, let's follow any SSR app from the moment a pull request is merged. Click through the steps or press Play.&lt;/p&gt;
&lt;div class="stepper" data-stepper data-play="▶ Play" data-pause="❚❚ Pause"&gt;
&lt;p&gt;&lt;em&gt;Deploy-time and request-time flow: repo, CI, S3, CloudFront, Route 53, server&lt;/em&gt;&lt;/p&gt;
&lt;ol class="steps"&gt;
&lt;h4&gt;Write the SSR app&lt;/h4&gt;&lt;p&gt;You have a Next.js or TanStack Start project in git. The pages are rendered on a server first, then hydrated in the browser so they become interactive.&lt;/p&gt;
&lt;h4&gt;Merge the PR to main&lt;/h4&gt;&lt;p&gt;Merging to main triggers your pipeline (GitHub Actions, for example). Nothing is live yet. The pipeline's job is to turn your source code into things AWS can serve.&lt;/p&gt;
&lt;h4&gt;Build the project&lt;/h4&gt;&lt;p&gt;The build gives you two kinds of output: &lt;b&gt;static files&lt;/b&gt; (hashed JS, CSS, images) and &lt;b&gt;server code&lt;/b&gt; that renders the pages.&lt;/p&gt;&lt;p&gt;Next.js output has to be reshaped to run on AWS, and &lt;a href="https://opennext.js.org/aws"&gt;OpenNext&lt;/a&gt; does that. TanStack Start gets its server output from &lt;a href="https://nitro.build/deploy/providers/aws"&gt;Nitro&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Save the static files in S3&lt;/h4&gt;&lt;p&gt;The static files go to a private S3 bucket. File names include a content hash, so they can be cached for a very long time. Next.js, for example, marks this kind of asset as &lt;code&gt;immutable&lt;/code&gt; with a one-year max-age (&lt;a href="https://nextjs.org/docs/app/guides/self-hosting"&gt;docs&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;CloudFront serves those files&lt;/h4&gt;&lt;p&gt;S3 only stores files. CloudFront sits in front of it and keeps copies at edge locations near your users. You also configure the bucket so that only CloudFront can read it.&lt;/p&gt;
&lt;h4&gt;Route 53 points your domain at it&lt;/h4&gt;&lt;p&gt;A Route 53 alias record points &lt;code&gt;yourapp.com&lt;/code&gt; to the CloudFront distribution. For HTTPS you also need a certificate from ACM, and it has to be created in &lt;code&gt;us-east-1&lt;/code&gt; because that is where CloudFront looks for it (&lt;a href="https://sst.dev/docs/component/aws/nextjs"&gt;SST docs&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;But who renders the HTML?&lt;/h4&gt;&lt;p&gt;S3 can't run code, and SSR needs code running on every request. So the pipeline also deploys the server code to something that can execute it.&lt;/p&gt;&lt;p&gt;In this article that is &lt;b&gt;AWS Lambda&lt;/b&gt;, the serverless option. You could also use containers on ECS or a plain Node/Docker server (&lt;a href="https://opennext.js.org/aws"&gt;OpenNext&lt;/a&gt;, &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/hosting"&gt;TanStack hosting&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;How the server reaches your users&lt;/h4&gt;&lt;p&gt;CloudFront answers file requests from its cache. Everything else goes to the server, which returns the HTML, and CloudFront hands it back to the browser. Users never talk to the server directly.&lt;/p&gt;&lt;p&gt;The Lambda details are in &lt;a href="#lambda"&gt;the last section&lt;/a&gt;. You don't need them to follow the rest.&lt;/p&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;h2 id="adds"&gt;2. What each framework adds&lt;/h2&gt;
&lt;p&gt;The setup above is the same for both frameworks. Pick one to see what extra it brings on the AWS side.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Animated request path from browser through Route 53 and CloudFront to S3 or Lambda, with extra services per framework&lt;/em&gt;&lt;/p&gt;
&lt;p class="note"&gt;The server box is Lambda here. There is more about it at the end.&lt;/p&gt;
&lt;div&gt;&lt;h4&gt;Next.js&lt;/h4&gt;
&lt;p class="meta"&gt;Next.js · via &lt;a href="https://opennext.js.org/aws"&gt;OpenNext&lt;/a&gt; · resources per &lt;a href="https://sst.dev/docs/component/aws/nextjs"&gt;SST&lt;/a&gt;&lt;/p&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;SSG · SSR · ISR&lt;/li&gt;&lt;li&gt;Image optimization&lt;/li&gt;&lt;li&gt;Middleware&lt;/li&gt;&lt;li&gt;Documented self-hosting&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Community adapter&lt;/li&gt;&lt;li&gt;6+ AWS resource types&lt;/li&gt;&lt;li&gt;25 CloudFront behaviors max&lt;/li&gt;&lt;li&gt;No native-Windows build&lt;/li&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;div&gt;&lt;h4&gt;TanStack Start&lt;/h4&gt;
&lt;p class="meta"&gt;TanStack Start · via &lt;a href="https://nitro.build/deploy/providers/aws"&gt;Nitro&lt;/a&gt; · &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/hosting"&gt;hosting guide&lt;/a&gt;&lt;/p&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;Small AWS footprint&lt;/li&gt;&lt;li&gt;Host-agnostic server&lt;/li&gt;&lt;li&gt;Optional Lambda streaming&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;AWS not a listed target&lt;/li&gt;&lt;li&gt;Community recipes only&lt;/li&gt;&lt;li&gt;No ISR / image tool seen&lt;/li&gt;&lt;li&gt;Release candidate (Nov 2025)&lt;/li&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="code"&gt;3. Where your code runs&lt;/h2&gt;
&lt;p&gt;This is the biggest difference between the two, and most of what comes next follows from it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Which components are shipped to the browser in each framework&lt;/em&gt;&lt;/p&gt;
&lt;div&gt;&lt;h4&gt;Next.js&lt;/h4&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;Server Components by default (&lt;a href="https://nextjs.org/docs/app/getting-started/server-and-client-components"&gt;docs&lt;/a&gt;)&lt;/li&gt;&lt;li&gt;Secrets stay server-side&lt;/li&gt;&lt;li&gt;Less JS to the browser&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;&lt;code&gt;'use client'&lt;/code&gt; pulls in its whole import graph&lt;/li&gt;&lt;li&gt;No React context in Server Components&lt;/li&gt;&lt;li&gt;Props must be serializable&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;b&gt;Watch out:&lt;/b&gt; &lt;code&gt;'use client'&lt;/code&gt; marks a boundary in the module graph, not on a single component. If you put it on a big file, everything that file imports goes to the browser. Keep it on small, leaf-level components.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;&lt;h4&gt;TanStack Start&lt;/h4&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;One mental model: it's just React + router&lt;/li&gt;&lt;li&gt;Explicit server code: &lt;code&gt;createServerFn&lt;/code&gt;&lt;/li&gt;&lt;li&gt;Server handler never reaches client bundle&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Isomorphic by default → &lt;b&gt;loaders run on the client too&lt;/b&gt;&lt;/li&gt;&lt;li&gt;Module-level &lt;code&gt;process.env&lt;/code&gt; can leak&lt;/li&gt;&lt;li&gt;Server Components: experimental, opt-in&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;b&gt;Watch out:&lt;/b&gt; the docs are direct about it: &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/execution-model"&gt;"Route loaders are isomorphic"&lt;/a&gt;. They run on the client too, so a secret or a database call inside a loader is a bug waiting to happen. Anything sensitive should live behind &lt;code&gt;createServerFn&lt;/code&gt; or &lt;code&gt;createServerOnlyFn&lt;/code&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id="fresh"&gt;4. How content gets fresh&lt;/h2&gt;
&lt;p&gt;This part decides how much infrastructure you run and how much you have to look after.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Pipeline for refreshing content in each framework&lt;/em&gt;&lt;/p&gt;
&lt;div&gt;&lt;h4&gt;Next.js&lt;/h4&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;Time-based: stale-while-revalidate&lt;/li&gt;&lt;li&gt;On-demand: tags, &lt;code&gt;revalidatePath&lt;/code&gt;&lt;/li&gt;&lt;li&gt;No redeploy to update content&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Cache + tag state must be shared across instances&lt;/li&gt;&lt;li&gt;HTML and RSC must be cached together&lt;/li&gt;&lt;li&gt;More AWS parts to monitor&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;More detail in the docs on &lt;a href="https://nextjs.org/docs/app/guides/how-revalidation-works"&gt;how revalidation works&lt;/a&gt; and in &lt;a href="https://sst.dev/docs/component/aws/nextjs"&gt;SST's list of resources&lt;/a&gt;. One thing to know: if a CDN caches the HTML and the RSC payload with different TTLs, users can see mismatched content when they navigate on the client.&lt;/p&gt;
&lt;p class="opinion inf"&gt;I drew the pipeline above in a simplified order.&lt;/p&gt;
&lt;p class="opinion"&gt;Of these, the HTML/RSC mismatch is the one I would expect to bite first.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;&lt;h4&gt;TanStack Start&lt;/h4&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;Prerender is plain static files&lt;/li&gt;&lt;li&gt;Simplest cache story: S3 + CloudFront&lt;/li&gt;&lt;li&gt;Navigation data via router loader cache&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;No ISR in the &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering"&gt;prerender docs&lt;/a&gt;&lt;/li&gt;&lt;li&gt;Dynamic routes prerender only if linked + &lt;code&gt;crawlLinks&lt;/code&gt;&lt;/li&gt;&lt;li&gt;New content = rebuild, or runtime fetch&lt;/li&gt;&lt;/ul&gt;
&lt;p class="opinion"&gt;If your catalog changes every hour, Start means rebuilding on a schedule or rendering live. That is fine for a lot of apps. But if editors expect to publish and see it live in seconds, you are better off paying for the Next/OpenNext machinery.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id="routes"&gt;5. Rendering strategy per route&lt;/h2&gt;
&lt;div&gt;&lt;h4&gt;Next.js&lt;/h4&gt;
&lt;div class="lane2"&gt;&lt;span class="lab"&gt;Static&lt;/span&gt;&lt;span class="cell s"&gt;Prerendered HTML&lt;/span&gt;&lt;span class="cell"&gt;CloudFront serves it&lt;/span&gt;&lt;span class="cell"&gt;Cache-Control: public&lt;/span&gt;&lt;/div&gt;
&lt;div class="lane2"&gt;&lt;span class="lab"&gt;Dynamic&lt;/span&gt;&lt;span class="cell s"&gt;Lambda renders per request&lt;/span&gt;&lt;span class="cell"&gt;cookies / headers used&lt;/span&gt;&lt;span class="cell"&gt;private, no-store&lt;/span&gt;&lt;/div&gt;
&lt;div class="lane2"&gt;&lt;span class="lab"&gt;Streamed&lt;/span&gt;&lt;span class="cell s"&gt;Shell first&lt;/span&gt;&lt;span class="cell"&gt;Suspense chunks as ready&lt;/span&gt;&lt;span class="cell x"&gt;needs unbuffered path&lt;/span&gt;&lt;/div&gt;
&lt;p class="note"&gt;Whether a route is static or dynamic depends on which APIs it uses (&lt;a href="https://nextjs.org/docs/app/guides/self-hosting"&gt;docs&lt;/a&gt;). You don't declare it; Next works it out from your code.&lt;/p&gt;
&lt;p class="opinion"&gt;Next works it out from what your code touches. That means less typing but more surprises: a harmless-looking &lt;code&gt;cookies()&lt;/code&gt; call can silently turn a page dynamic.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;&lt;h4&gt;TanStack Start&lt;/h4&gt;
&lt;div class="lane2"&gt;&lt;span class="lab"&gt;&lt;code&gt;ssr: true&lt;/code&gt;&lt;/span&gt;&lt;span class="cell s"&gt;loader on server&lt;/span&gt;&lt;span class="cell s"&gt;component → HTML&lt;/span&gt;&lt;span class="cell c"&gt;hydrate on client&lt;/span&gt;&lt;/div&gt;
&lt;div class="lane2"&gt;&lt;span class="lab"&gt;&lt;code&gt;'data-only'&lt;/code&gt;&lt;/span&gt;&lt;span class="cell s"&gt;loader on server&lt;/span&gt;&lt;span class="cell c"&gt;component on client only&lt;/span&gt;&lt;/div&gt;
&lt;div class="lane2"&gt;&lt;span class="lab"&gt;&lt;code&gt;ssr: false&lt;/code&gt;&lt;/span&gt;&lt;span class="cell c"&gt;loader on client&lt;/span&gt;&lt;span class="cell c"&gt;component on client&lt;/span&gt;&lt;/div&gt;
&lt;p class="note"&gt;You declare it per route with &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/selective-ssr"&gt;selective SSR&lt;/a&gt;. A child route can only be stricter than its parent, never looser.&lt;/p&gt;
&lt;p class="opinion"&gt;Start asks you to declare it per route. That takes more typing, but nothing changes behind your back.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id="cwv"&gt;6. Core Web Vitals&lt;/h2&gt;
&lt;p&gt;Google judges page experience with three numbers. A page counts as "good" when it hits the target for 75% of visits (&lt;a href="https://web.dev/articles/vitals"&gt;web.dev&lt;/a&gt;).&lt;/p&gt;
&lt;div class="tiles fadein"&gt;
&lt;div class="tile" style="border-color:var(--next)"&gt;LCP&lt;b&gt;≤ 2.5 s&lt;/b&gt;main content visible&lt;/div&gt;
&lt;div class="tile" style="border-color:var(--tan)"&gt;INP&lt;b&gt;≤ 200 ms&lt;/b&gt;reacts to taps and clicks&lt;/div&gt;
&lt;div class="tile"&gt;CLS&lt;b&gt;≤ 0.1&lt;/b&gt;nothing jumps around&lt;/div&gt;
&lt;/div&gt;
&lt;h3&gt;What the framework does not decide&lt;/h3&gt;
&lt;p&gt;A slow first byte (TTFB) hurts LCP, but TTFB is not one of the three vitals, and most of it has little to do with the framework. It comes from how fast your API and database respond, how far the server is from the user, and whether CloudFront can cache the response (&lt;a href="https://web.dev/articles/ttfb"&gt;web.dev&lt;/a&gt;). A server-rendered page can even have a higher TTFB than a client-rendered one and still win on LCP. So don't choose a framework because of TTFB. Fix your slowest API call first.&lt;/p&gt;
&lt;h3&gt;Where the frameworks do differ&lt;/h3&gt;
&lt;div class="scroll fadein"&gt;&lt;table&gt;
&lt;tr&gt;&lt;th&gt;What&lt;/th&gt;&lt;th class="next"&gt;Next.js&lt;/th&gt;&lt;th class="tan"&gt;TanStack Start&lt;/th&gt;&lt;th&gt;Edge&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LCP · images&lt;/td&gt;&lt;td&gt;Built-in image optimization (its own Lambda on AWS)&lt;/td&gt;&lt;td&gt;Plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; with the standard checklist below, served from S3 through CloudFront&lt;/td&gt;&lt;td&gt;&lt;span class="flag"&gt;Even&lt;/span&gt; if you follow the checklist; Next automates the sizing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LCP · cached HTML&lt;/td&gt;&lt;td&gt;Fully static pages are &lt;code&gt;public&lt;/code&gt;, so CloudFront can cache them. ISR keeps them fresh. Dynamic pages are &lt;code&gt;private, no-store&lt;/code&gt; and always reach the server (&lt;a href="https://nextjs.org/docs/app/guides/self-hosting"&gt;docs&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;Prerendered pages are plain files, also cacheable (&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering"&gt;docs&lt;/a&gt;). No ISR, so fresh content means a rebuild&lt;/td&gt;&lt;td&gt;&lt;span class="flag"&gt;Even for static&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LCP · streaming&lt;/td&gt;&lt;td&gt;Streams with Suspense&lt;/td&gt;&lt;td&gt;Streaming SSR (&lt;a href="https://www.infoq.com/news/2025/11/tanstack-start-v1"&gt;InfoQ&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;&lt;span class="flag"&gt;Even&lt;/span&gt; if nothing on the path buffers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;INP · JS in the browser&lt;/td&gt;&lt;td&gt;Server Components send no JS of their own; only &lt;code&gt;'use client'&lt;/code&gt; parts hydrate (&lt;a href="https://nextjs.org/docs/app/getting-started/server-and-client-components"&gt;docs&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;Components are server-rendered and hydrated by default (&lt;a href="https://tanstack.com/start/latest/docs/framework/react/start-vs-nextjs"&gt;docs&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;&lt;span class="flag"&gt;Next, for content-heavy pages&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CLS&lt;/td&gt;&lt;td&gt;Mostly your markup: image sizes, fonts, late-loading banners&lt;/td&gt;&lt;td&gt;Same&lt;/td&gt;&lt;td&gt;&lt;span class="flag"&gt;Even&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;A good LCP image in TanStack Start&lt;/h3&gt;
&lt;p&gt;These practices come from &lt;a href="https://web.dev/articles/optimize-lcp"&gt;web.dev's LCP guide&lt;/a&gt; and apply to any framework:&lt;/p&gt;
&lt;ul class="chips pro"&gt;
&lt;li&gt;Put the image in the server-rendered HTML&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetchpriority="high"&lt;/code&gt; on the hero only&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;loading="lazy"&lt;/code&gt; on it&lt;/li&gt;
&lt;li&gt;&lt;code&gt;srcset&lt;/code&gt; for the right size&lt;/li&gt;
&lt;li&gt;AVIF or WebP&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;// routes/index.tsx
export const Route = createFileRoute('/')({
head: () =&amp;gt; ({
links: [
{ rel: 'preload', as: 'image', href: '/img/hero-1200.avif', fetchPriority: 'high' },
],
}),
component: Home,
})
const Home = () =&amp;gt; (
&amp;lt;img
src="https://cmarcal-roberto.dev/img/hero-1200.avif"
srcSet="/img/hero-600.avif 600w, /img/hero-1200.avif 1200w"
sizes="100vw"
width={1200}
height={630}
fetchPriority="high"
alt="Football pitch at sunset"
/&amp;gt;
)&lt;/pre&gt;
&lt;p class="note"&gt;In Start you add tags to the page head through the route's &lt;code&gt;head&lt;/code&gt; option (&lt;a href="https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management"&gt;docs&lt;/a&gt;). The docs show a font preload, so the image version above is my own adaptation. The files sit in S3 and CloudFront caches them like any other asset. Since the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; is already in the server-rendered HTML, the preload is optional; it matters more when the image is referenced from CSS or JavaScript.&lt;/p&gt;
&lt;p&gt;One public test measured 116 KB of client JavaScript for TanStack Start against 193 KB for Next.js in the same dashboard app (&lt;a href="https://blog.logrocket.com/tanstack-start-rsc-vs-next-js-rsc-performance-dx-production-readiness/"&gt;LogRocket&lt;/a&gt;). It is a single app, so read it as "the framework runtime can matter", not as "one always wins". TanStack's own comparison page &lt;a href="https://tanstack.com/start/latest/docs/framework/react/start-vs-nextjs"&gt;declines to name a winner on runtime size&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I didn't find &lt;b&gt;any measured Core Web Vitals comparison&lt;/b&gt; between the two. Run Lighthouse and look at field data (CrUX) on your own app.&lt;/p&gt;
&lt;p class="opinion"&gt;A content site with few interactive parts benefits most from Next's Server Components and image pipeline. A very interactive app ships most of its JS in either framework, so the gap gets smaller.&lt;/p&gt;
&lt;h2 id="ops"&gt;7. What you own when it breaks&lt;/h2&gt;
&lt;div&gt;&lt;h4&gt;Next.js&lt;/h4&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Adapter must keep up with Next releases&lt;/li&gt;&lt;li&gt;Maintainers have limited capacity (&lt;a href="https://opennext.js.org/aws"&gt;OpenNext&lt;/a&gt;)&lt;/li&gt;&lt;li&gt;25 CloudFront behaviors per distribution&lt;/li&gt;&lt;li&gt;Image Lambda memory + cold starts&lt;/li&gt;&lt;li&gt;Cache/tag consistency bugs&lt;/li&gt;&lt;/ul&gt;
&lt;p class="opinion"&gt;The failure surface is wide, but a lot of people run this setup, so most errors are searchable. Pin your Next and OpenNext versions together and upgrade them as one unit.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;&lt;h4&gt;TanStack Start&lt;/h4&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Release candidate as of Nov 2025 (&lt;a href="https://www.infoq.com/news/2025/11/tanstack-start-v1"&gt;InfoQ&lt;/a&gt;)&lt;/li&gt;&lt;li&gt;Nitro Vite plugin "under active development"&lt;/li&gt;&lt;li&gt;No first-party AWS runbook&lt;/li&gt;&lt;li&gt;You build image + cache layers yourself&lt;/li&gt;&lt;/ul&gt;
&lt;p class="opinion"&gt;The failure surface is smaller, but fewer people have been there before. When something breaks you will be reading Nitro and Start source code more than Stack Overflow. Plan for that, and check the current release status before you commit.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id="lambda"&gt;8. Going deeper: Lambda and mutations&lt;/h2&gt;&lt;p&gt;This part is optional. You can skip it and still make the call.&lt;/p&gt;&lt;h3&gt;How a mutation reaches Lambda&lt;/h3&gt;
&lt;p&gt;Form posts and server functions end up as an HTTPS call that goes through CloudFront into your function. Each framework tends to fail in a different way here.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;A mutation request traveling from browser through CloudFront to one or more Lambda instances&lt;/em&gt;&lt;/p&gt;
&lt;div&gt;&lt;h4&gt;Next.js&lt;/h4&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Lambda = many instances&lt;/li&gt;&lt;li&gt;Set &lt;code&gt;NEXT_SERVER_ACTIONS_ENCRYPTION_KEY&lt;/code&gt; or hit "Failed to find Server Action"&lt;/li&gt;&lt;li&gt;Rolling deploys: set &lt;code&gt;deploymentId&lt;/code&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Source: the &lt;a href="https://nextjs.org/docs/app/guides/self-hosting"&gt;self-hosting guide&lt;/a&gt;, which describes this for multi-server setups.&lt;/p&gt;
&lt;p class="opinion inf"&gt;Lambda runs many instances, so I am applying that guidance to it. The docs don't say it about Lambda specifically.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;&lt;h4&gt;TanStack Start&lt;/h4&gt;
&lt;ul class="chips pro"&gt;&lt;li&gt;Build swaps handler for RPC stub&lt;/li&gt;&lt;li&gt;Validator (e.g. Zod) is first-class&lt;/li&gt;&lt;li&gt;CSRF middleware by default&lt;/li&gt;&lt;/ul&gt;
&lt;ul class="chips con"&gt;&lt;li&gt;Same-origin only: public APIs need server routes&lt;/li&gt;&lt;li&gt;Define &lt;code&gt;src/start.ts&lt;/code&gt; → you re-add CSRF&lt;/li&gt;&lt;li&gt;Auth belongs in each handler&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;From the &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/server-functions"&gt;server functions guide&lt;/a&gt;: &lt;code&gt;beforeLoad&lt;/code&gt; "is not the data boundary".&lt;/p&gt;
&lt;p class="opinion"&gt;Treat every server function like a public endpoint and do the authorization inside it. The same goes for Next Server Actions.&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Gotchas that hit both&lt;/h3&gt;
&lt;ul class="chips con"&gt;
&lt;li&gt;POST requests through OAC need &lt;code&gt;x-amz-content-sha256&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Nothing on the path may buffer streamed responses&lt;/li&gt;
&lt;li&gt;With many Lambda instances, Next needs a shared cache and the same encryption key&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="verdict"&gt;Verdict&lt;/h2&gt;
&lt;p&gt;Choose &lt;span class="next"&gt;Next.js&lt;/span&gt; if content has to go live in seconds, you want images and middleware handled for you, and you are fine owning the OpenNext pieces. Choose &lt;span class="tan"&gt;TanStack Start&lt;/span&gt; if you want the smallest AWS footprint, explicit server/client boundaries and portability, and you can build the extras yourself.&lt;/p&gt;
&lt;p class="opinion"&gt;Prototype your hardest route in both before you commit, and measure Core Web Vitals on your own app.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul class="sources"&gt;&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/guides/self-hosting"&gt;Next.js: Self-hosting&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/getting-started/server-and-client-components"&gt;Next.js: Server and Client Components&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/guides/how-revalidation-works"&gt;Next.js: How revalidation works&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://opennext.js.org/aws"&gt;OpenNext for AWS&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://sst.dev/docs/component/aws/nextjs"&gt;SST: Nextjs component (AWS resources, limits)&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/hosting"&gt;TanStack Start: Hosting&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/execution-model"&gt;TanStack Start: Execution model&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/server-functions"&gt;TanStack Start: Server functions&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/selective-ssr"&gt;TanStack Start: Selective SSR&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering"&gt;TanStack Start: Static prerendering&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/start-vs-nextjs"&gt;TanStack: Start vs Next.js&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://nitro.build/deploy/providers/aws"&gt;Nitro: AWS Lambda preset&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html"&gt;AWS: Restrict access to a Lambda function URL origin&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://web.dev/articles/vitals"&gt;web.dev: Core Web Vitals&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://web.dev/articles/ttfb"&gt;web.dev: TTFB&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://www.infoq.com/news/2025/11/tanstack-start-v1"&gt;InfoQ: TanStack Start release candidate (Nov 2025)&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://blog.logrocket.com/tanstack-start-rsc-vs-next-js-rsc-performance-dx-production-readiness/"&gt;LogRocket: TanStack Start RSC vs Next.js RSC&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;/div&gt;</content:encoded></item></channel></rss>