Next.js vs TanStack Start on plain AWS

· 11 min read AWSNext.jsTanStack StartSSR

S3, CloudFront, Route 53 and Lambda, no Vercel

What actually changes when the same SSR app lands on the same AWS setup.

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.

TanStack Start via Nitro

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.

Everything here links to a source. The boxes marked Opinion or Inference are my own reading.

  1. 1. From merged PR to a page in the browser
  2. 2. What each framework adds
  3. 3. Where your code runs
  4. 4. How content gets fresh
  5. 5. Rendering strategy per route
  6. 6. Core Web Vitals
  7. 7. What you own when it breaks
  8. 8. Going deeper: Lambda and mutations

1. From merged PR to a page in the browser

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.

Deploy time · once per mergeRequest time · every visitGit repoPR → mainCI pipelinebuild + deployBrowserRoute 53your domainCloudFrontedge cacheS3 bucketstatic filesServerrenders HTML

2. What each framework adds

The setup above is the same for both frameworks. Pick one to see what extra it brings on the AWS side.

BrowserRoute 53alias → CFACM (us-east-1)CloudFrontS3 · OAChashed assetsLambda URL · OACSSR serverOpenNext extrasImage LambdaDynamoDB + SQS (ISR)Warmer · CF FunctionsNitro aws_lambdanothing else required

The server box is Lambda here. There is more about it at the end.

Next.js · via OpenNext · resources per SST

  • SSG · SSR · ISR
  • Image optimization
  • Middleware
  • Documented self-hosting
  • Community adapter
  • 6+ AWS resource types
  • 25 CloudFront behaviors max
  • No native-Windows build

TanStack Start · via Nitro · hosting guide

  • Small AWS footprint
  • Host-agnostic server
  • Optional Lambda streaming
  • AWS not a listed target
  • Community recipes only
  • No ISR / image tool seen
  • Release candidate (Nov 2025)

3. Where your code runs

This is the biggest difference between the two, and most of what comes next follows from it.

Browser JSLayoutPageHeaderChartButtondashed = Server Component (HTML + payload only)Button islandReact runtime+ RSC payload (data)LayoutPage + loaderHeaderChartButtonsolid = isomorphic: SSR'd, then hydratedcreateServerFn()All 5 componentsloader code (runs on both)RPC stub, not the handler
  • Server Components by default (docs)
  • Secrets stay server-side
  • Less JS to the browser
  • 'use client' pulls in its whole import graph
  • No React context in Server Components
  • Props must be serializable

Watch out: 'use client' 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.

  • One mental model: it's just React + router
  • Explicit server code: createServerFn
  • Server handler never reaches client bundle
  • Isomorphic by default → loaders run on the client too
  • Module-level process.env can leak
  • Server Components: experimental, opt-in

Watch out: the docs are direct about it: "Route loaders are isomorphic". 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 createServerFn or createServerOnlyFn.

4. How content gets fresh

This part decides how much infrastructure you run and how much you have to look after.

revalidateTag()on demand / TTLDynamoDBtag cacheSQSrevalidation queueRevalidation fnre-renderCache (S3)HTML + RSCstale copy keeps serving until the fresh one landsgit pushcontent changedBuild + prerenderstatic HTML filesUpload to S3new hashed assetsCF invalidationpurge pathsor fetch live data in loaders and skip prerendering
  • Time-based: stale-while-revalidate
  • On-demand: tags, revalidatePath
  • No redeploy to update content
  • Cache + tag state must be shared across instances
  • HTML and RSC must be cached together
  • More AWS parts to monitor

More detail in the docs on how revalidation works and in SST's list of resources. 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.

I drew the pipeline above in a simplified order.

Of these, the HTML/RSC mismatch is the one I would expect to bite first.

  • Prerender is plain static files
  • Simplest cache story: S3 + CloudFront
  • Navigation data via router loader cache
  • No ISR in the prerender docs
  • Dynamic routes prerender only if linked + crawlLinks
  • New content = rebuild, or runtime fetch

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.

5. Rendering strategy per route

StaticPrerendered HTMLCloudFront serves itCache-Control: public
DynamicLambda renders per requestcookies / headers usedprivate, no-store
StreamedShell firstSuspense chunks as readyneeds unbuffered path

Whether a route is static or dynamic depends on which APIs it uses (docs). You don't declare it; Next works it out from your code.

Next works it out from what your code touches. That means less typing but more surprises: a harmless-looking cookies() call can silently turn a page dynamic.

ssr: trueloader on servercomponent → HTMLhydrate on client
'data-only'loader on servercomponent on client only
ssr: falseloader on clientcomponent on client

You declare it per route with selective SSR. A child route can only be stricter than its parent, never looser.

Start asks you to declare it per route. That takes more typing, but nothing changes behind your back.

6. Core Web Vitals

Google judges page experience with three numbers. A page counts as "good" when it hits the target for 75% of visits (web.dev).

LCP≤ 2.5 smain content visible
INP≤ 200 msreacts to taps and clicks
CLS≤ 0.1nothing jumps around

What the framework does not decide

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 (web.dev). 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.

Where the frameworks do differ

WhatNext.jsTanStack StartEdge
LCP · imagesBuilt-in image optimization (its own Lambda on AWS)Plain <img> with the standard checklist below, served from S3 through CloudFrontEven if you follow the checklist; Next automates the sizing
LCP · cached HTMLFully static pages are public, so CloudFront can cache them. ISR keeps them fresh. Dynamic pages are private, no-store and always reach the server (docs)Prerendered pages are plain files, also cacheable (docs). No ISR, so fresh content means a rebuildEven for static
LCP · streamingStreams with SuspenseStreaming SSR (InfoQ)Even if nothing on the path buffers
INP · JS in the browserServer Components send no JS of their own; only 'use client' parts hydrate (docs)Components are server-rendered and hydrated by default (docs)Next, for content-heavy pages
CLSMostly your markup: image sizes, fonts, late-loading bannersSameEven

A good LCP image in TanStack Start

These practices come from web.dev's LCP guide and apply to any framework:

  • Put the image in the server-rendered HTML
  • fetchpriority="high" on the hero only
  • Never loading="lazy" on it
  • srcset for the right size
  • AVIF or WebP
// routes/index.tsx
export const Route = createFileRoute('/')({
  head: () => ({
    links: [
      { rel: 'preload', as: 'image', href: '/img/hero-1200.avif', fetchPriority: 'high' },
    ],
  }),
  component: Home,
})

const Home = () => (
  <img
    src="/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"
  />
)

In Start you add tags to the page head through the route's head option (docs). 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 <img> is already in the server-rendered HTML, the preload is optional; it matters more when the image is referenced from CSS or JavaScript.

One public test measured 116 KB of client JavaScript for TanStack Start against 193 KB for Next.js in the same dashboard app (LogRocket). It is a single app, so read it as "the framework runtime can matter", not as "one always wins". TanStack's own comparison page declines to name a winner on runtime size.

I didn't find any measured Core Web Vitals comparison between the two. Run Lighthouse and look at field data (CrUX) on your own app.

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.

7. What you own when it breaks

  • Adapter must keep up with Next releases
  • Maintainers have limited capacity (OpenNext)
  • 25 CloudFront behaviors per distribution
  • Image Lambda memory + cold starts
  • Cache/tag consistency bugs

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.

  • Release candidate as of Nov 2025 (InfoQ)
  • Nitro Vite plugin "under active development"
  • No first-party AWS runbook
  • You build image + cache layers yourself

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.

8. Going deeper: Lambda and mutations

This part is optional. You can skip it and still make the call.

How a mutation reaches Lambda

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.

BrowserCloudFrontPOSTOAC needs x-amz-content-sha256(both frameworks)Instance Aencrypts closure varsInstance Bmust share the keycreateServerFn handlervalidator → handlersame-origin / CSRF check
  • Lambda = many instances
  • Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY or hit "Failed to find Server Action"
  • Rolling deploys: set deploymentId

Source: the self-hosting guide, which describes this for multi-server setups.

Lambda runs many instances, so I am applying that guidance to it. The docs don't say it about Lambda specifically.

  • Build swaps handler for RPC stub
  • Validator (e.g. Zod) is first-class
  • CSRF middleware by default
  • Same-origin only: public APIs need server routes
  • Define src/start.ts → you re-add CSRF
  • Auth belongs in each handler

From the server functions guide: beforeLoad "is not the data boundary".

Treat every server function like a public endpoint and do the authorization inside it. The same goes for Next Server Actions.

Gotchas that hit both

  • POST requests through OAC need x-amz-content-sha256
  • Nothing on the path may buffer streamed responses
  • With many Lambda instances, Next needs a shared cache and the same encryption key

Verdict

Choose Next.js 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 TanStack Start if you want the smallest AWS footprint, explicit server/client boundaries and portability, and you can build the extras yourself.

Prototype your hardest route in both before you commit, and measure Core Web Vitals on your own app.

Sources

← All articles