Hosting TanStack Start SSR on AWS with AWS Blocks
How to deploy a TanStack Start SSR app to CloudFront + Lambda using the AWS Blocks Hosting construct and its nitro adapter, demonstrated with a minimal working example.
AWS Blocks is an AWS toolkit that lets you define your backend and infrastructure together in TypeScript. When I moved a personal side project to AWS Blocks, I found that the TanStack Start SSR frontend I was using could be hosted on CloudFront + Lambda as well. The catch is that the documentation focuses on Next.js and SPA frontends, so getting TanStack Start running takes a little extra work. In this article I'll walk through the key points, using a minimal working example.
The full source code is here:
What is AWS Blocks?
AWS Blocks is a toolkit where you compose your backend from parts called Building Blocks: authentication, key-value stores, tables, file buckets, and so on. What stands out is the development experience.
npm run dev runs every Block as a local mock, so you can develop without an AWS account. When you deploy, the same code switches over to the real services like DynamoDB and Lambda.You can scaffold a project with
create-blocks-app:1
npx @aws-blocks/create-blocks-app my-app --template reactMy example starts from this
react template (a Vite + React SPA) and swaps the frontend for TanStack Start. The backend is as small as it gets: no auth, no database, just one API method.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// aws-blocks/index.ts
import { ApiNamespace, Scope } from '@aws-blocks/blocks';
const scope = new Scope('tanstack-start-example');
export const api = new ApiNamespace(scope, 'api', () => ({
/** Returns information about where the API is running — used to verify SSR. */
async getServerInfo() {
return {
message: 'Hello from AWS Blocks API!',
region: process.env.AWS_REGION ?? 'local',
serverTime: new Date().toISOString(),
};
},
}));On the frontend,
import { api } from 'aws-blocks' gives you a fully typed client for this API. There are no HTTP endpoints to configure and no client code to generate.Architecture
Here is what a deployment looks like. CloudFront routes each request by path: static assets go to S3, SSR goes to a Lambda function running the Nitro build, and API calls go to the Blocks API Lambda. Both the SSR and API Lambdas sit behind their own API Gateway (REST API).

Architecture diagram
Three things make TanStack Start work in this setup.
Point 1: Build for aws-lambda with the nitro plugin
Current versions of TanStack Start don't bundle Nitro. Nitro is a full-stack server framework compatible with any runtime and any deployment target; at build time you pick a
preset and it emits a server bundle tailored to where you're deploying. vite build alone produces a server bundle, but not an entry point that runs on Lambda. Adding the nitro Vite plugin fixes that by generating an .output/ directory that includes a Lambda handler.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// vite.config.ts
import { defineConfig } from 'vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { nitro } from 'nitro/vite';
import viteReact from '@vitejs/plugin-react';
export default defineConfig(({ command }) => ({
resolve: {
conditions: ['browser'],
},
plugins: [
tanstackStart(),
...(command === 'build' ? [nitro({ preset: 'aws-lambda', awsLambda: { streaming: true } })] : []),
viteReact(),
],
}));Two details matter here.
First, the nitro plugin is only enabled when
command === 'build'. Enabling it in the dev server conflicts with TanStack Start's own SSR and breaks things. In dev, TanStack Start handles SSR by itself, so nitro is only needed for production builds.Second,
awsLambda: { streaming: true } is required. The AWS Blocks Hosting construct serves SSR through API Gateway (REST API) response streaming, so a buffered handler gets you 502s from CloudFront. I lost some time on this one.Point 2: Set framework: 'nitro' on the Hosting construct
On the CDK side, all you change in the template-generated
aws-blocks/index.cdk.ts is the Hosting construct. The diff from the SPA template is two lines:1
2
3
4
5
6
7
8
9
10
// aws-blocks/index.cdk.ts (excerpt)
if (!sandboxMode) {
new Hosting(blocksStack, 'Hosting', {
root: join(__dirname, '..'),
buildCommand: 'npm run build',
framework: 'nitro', // ← serve .output/ via the nitro adapter
buildOutputDir: '.output', // ← 'dist' in the SPA template
api: blocksStack
});
}The documentation lists the
framework option as 'spa' | 'static' | 'nextjs', but the implementation ships a nitro adapter alongside Nuxt and Astro ones, and 'nitro' is accepted. Nitro's .output/ format is the same one Nuxt uses, so any framework that can produce it — TanStack Start included — can be hosted this way.Passing
api: blocksStack makes CloudFront proxy the /aws-blocks/api path to the backend Lambda, so the frontend and backend share one origin. No CORS configuration needed.Point 3: Call the Blocks API from a loader
A TanStack Start route loader runs on the server (the SSR Lambda) for the initial request, and in the browser for client-side navigation afterwards. The AWS Blocks client works the same way in both environments, so the loader is just:
1
2
3
4
5
6
7
8
// src/routes/index.tsx (excerpt)
import { createFileRoute } from '@tanstack/react-router';
import { api } from 'aws-blocks';
export const Route = createFileRoute('/')({
loader: () => api.getServerInfo(),
component: Home,
});This works because the client resolves the API URL differently per environment. In the SSR Lambda, the Hosting construct automatically injects a
BLOCKS_API_URL environment variable (the API Gateway endpoint of the Blocks API), and the client reads it. In the browser, the client fetches a config file from the hosting origin (/.blocks-sandbox/config.json) and calls the API through the CloudFront proxy. None of this is visible in your application code.My example has no authentication, so calling the API from the loader is all there is to it. If you need to call an authenticated API during SSR, use
withAuth (from @aws-blocks/blocks/server) to forward the browser's cookies to the API call. Cookie auto-detection is built in for Next.js and Nuxt, but TanStack Start isn't covered, so pull the cookie out of the request headers and pass it as the second argument:1
2
3
4
5
6
7
8
9
// For reference: calling an authenticated API during SSR (not part of the example)
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeader } from '@tanstack/react-start/server';
import { withAuth } from '@aws-blocks/blocks/server';
import { api } from 'aws-blocks';
const getMyPosts = createServerFn().handler(() =>
withAuth(() => api.listMyPosts(), getRequestHeader('cookie')),
);Deploy and verify
Deployment is a single script that comes with the template. It builds, synthesizes, and deploys in one go:
1
npm run deployWhen it finishes, it prints the CloudFront URL. The page shows the loader result, but the reliable way to confirm SSR is to view the page source:
1
2
3
<li>message: Hello from AWS Blocks API!</li>
<li>region: ap-northeast-1</li>
<li>serverTime: 2026-07-17T13:36:20.164Z</li>The API response is right there in the HTML — rendered by the SSR Lambda calling the Blocks API before any JavaScript reaches the browser. The page also has a button that re-fetches the same API from the browser, so you can see the same
api.getServerInfo() call working on both sides.One gotcha: the cached config.json
I hit one bug in AWS Blocks worth writing down. The
config.json that browsers use to resolve the API URL is first uploaded as a build-time placeholder and then overwritten with the real values during deployment. But the CloudFront cache key is internally rewritten to live under /builds/<buildId>/, so the post-deploy cache invalidation (/.blocks-sandbox/*) never matches it — and the placeholder can stay cached for up to a year. In that state, every API call from the browser fails.The example works around it with
patch-package, widening the invalidation path to /* (see the patches/ directory). It's applied automatically on npm install, so you don't have to think about it. I've also submitted a fix upstream ; once it's merged, the patch won't be needed.Wrapping up
Because the AWS Blocks Hosting construct ships a nitro adapter, any framework that can emit Nitro's
.output/ can be hosted — even ones that aren't on the officially supported list. For TanStack Start, the only changes needed were adding the nitro plugin to vite.config.ts and setting framework: 'nitro' on the Hosting construct.All the code is in the example repository.
npm run dev runs on local mocks, so you can try it without an AWS account:Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article