Experimental: transformAssets is experimental and subject to change.
When deploying to production, you may want to serve your static assets (JavaScript, CSS) from a CDN. The transformAssets option on createStartHandler lets you rewrite asset URLs at runtime - for example, prepending a CDN origin that is only known when the server starts.
transformAssetUrls still works, but it is deprecated and now delegates to transformAssets with a development warning.
Vite's base config is evaluated at build time. If your CDN URL is determined at deploy time (via environment variables, dynamic configuration, etc.), you need a way to rewrite URLs at runtime. transformAssets solves this for the URLs that TanStack Start manages in its manifest:
The simplest usage is passing a string. Every manifest asset URL will be prefixed with it:
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: process.env.CDN_ORIGIN || '',
})
export default createServerEntry({ fetch: handler })
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: process.env.CDN_ORIGIN || '',
})
export default createServerEntry({ fetch: handler })
If CDN_ORIGIN is https://cdn.example.com and an asset URL is /assets/index-abc123.js, the resulting URL will be https://cdn.example.com/assets/index-abc123.js.
When the string is empty (or not set), the URLs are left unchanged.
If you also need to set crossOrigin on manifest-managed <link> tags, use the object shorthand with prefix and crossOrigin:
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: {
prefix: process.env.CDN_ORIGIN || '',
crossOrigin: 'anonymous',
},
})
export default createServerEntry({ fetch: handler })
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: {
prefix: process.env.CDN_ORIGIN || '',
crossOrigin: 'anonymous',
},
})
export default createServerEntry({ fetch: handler })
crossOrigin accepts either a single value applied to all asset kinds, or a per-kind record (matching the HeadContent assetCrossOrigin shape):
transformAssets: {
prefix: 'https://cdn.example.com',
crossOrigin: {
modulepreload: 'anonymous',
stylesheet: 'use-credentials',
},
}
transformAssets: {
prefix: 'https://cdn.example.com',
crossOrigin: {
modulepreload: 'anonymous',
stylesheet: 'use-credentials',
},
}
Kinds not listed in the per-kind record receive no crossOrigin attribute. Like the string shorthand, the object shorthand is always cached (cache: true).
For more control, pass a callback that receives { kind, url } and returns a string, or { href, crossOrigin? } (or a Promise of either). By default, the transformed manifest is cached after the first request (cache: true), so the callback only runs once in production:
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: ({ kind, url }) => {
const href = `https://cdn.example.com${url}`
if (kind === 'modulepreload') {
return {
href,
crossOrigin: 'anonymous',
}
}
return { href }
},
})
export default createServerEntry({ fetch: handler })
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: ({ kind, url }) => {
const href = `https://cdn.example.com${url}`
if (kind === 'modulepreload') {
return {
href,
crossOrigin: 'anonymous',
}
}
return { href }
},
})
export default createServerEntry({ fetch: handler })
If you need per-request behavior (for example, choosing a CDN based on a header), use the object form with cache: false.
The kind parameter tells you what kind of asset URL is being transformed:
| kind | Description |
|---|---|
| 'modulepreload' | JS module preload URL (<link rel="modulepreload">) |
| 'stylesheet' | CSS stylesheet URL (<link rel="stylesheet">) |
| 'clientEntry' | Client entry module URL (used in import('...')) |
crossOrigin applies to manifest-managed link tags. For the client entry, returning { href } is equivalent to returning a string.
For per-request transforms - where the CDN URL depends on request-specific data like headers - use the object form with cache: false:
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
import { getRequest } from '@tanstack/react-start/server'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: {
transform: ({ kind, url }) => {
const region = getRequest().headers.get('x-region') || 'us'
const cdnBase =
region === 'eu'
? 'https://cdn-eu.example.com'
: 'https://cdn-us.example.com'
if (kind === 'modulepreload') {
return {
href: `${cdnBase}${url}`,
crossOrigin: 'anonymous',
}
}
return { href: `${cdnBase}${url}` }
},
cache: false,
},
})
export default createServerEntry({ fetch: handler })
// src/server.ts
import {
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createServerEntry } from '@tanstack/react-start/server-entry'
import { getRequest } from '@tanstack/react-start/server'
const handler = createStartHandler({
handler: defaultStreamHandler,
transformAssets: {
transform: ({ kind, url }) => {
const region = getRequest().headers.get('x-region') || 'us'
const cdnBase =
region === 'eu'
? 'https://cdn-eu.example.com'
: 'https://cdn-us.example.com'
if (kind === 'modulepreload') {
return {
href: `${cdnBase}${url}`,
crossOrigin: 'anonymous',
}
}
return { href: `${cdnBase}${url}` }
},
cache: false,
},
})
export default createServerEntry({ fetch: handler })
The object form accepts:
| Property | Type | Description |
|---|---|---|
| transform | string | (asset) => string | { href, crossOrigin? } | Promise<...> | A string prefix or callback, same as the shorthand forms above. |
| createTransform | (ctx: { warmup: true } | { warmup: false; request: Request }) => (asset) => string | { href, crossOrigin? } | Promise<...> | Async factory that runs once per manifest computation and returns a per-asset transform. Mutually exclusive with transform. |
| cache | boolean | Whether to cache the transformed manifest. Defaults to true. |
| warmup | boolean | When true, warms up the cached manifest on server startup (prod only). Defaults to false. |
If you need to do async work once per manifest computation (e.g. fetch a CDN origin from a service) and then transform many URLs, prefer createTransform:
transformAssets: {
cache: false,
async createTransform(ctx) {
if (ctx.warmup) {
return ({ url }) => ({ href: url })
}
const region = ctx.request.headers.get('x-region') || 'us'
const cdnBase = await fetchCdnBaseForRegion(region)
return ({ kind, url }) => {
if (kind === 'modulepreload') {
return {
href: `${cdnBase}${url}`,
crossOrigin: 'anonymous',
}
}
return { href: `${cdnBase}${url}` }
}
},
}
transformAssets: {
cache: false,
async createTransform(ctx) {
if (ctx.warmup) {
return ({ url }) => ({ href: url })
}
const region = ctx.request.headers.get('x-region') || 'us'
const cdnBase = await fetchCdnBaseForRegion(region)
return ({ kind, url }) => {
if (kind === 'modulepreload') {
return {
href: `${cdnBase}${url}`,
crossOrigin: 'anonymous',
}
}
return { href: `${cdnBase}${url}` }
}
},
}
By default, all forms of transformAssets cache the transformed manifest after the first request (cache: true). This means the transform function runs once on the first request, and the result is reused for every subsequent request in production.
| Form | Default cache | Behavior |
|---|---|---|
| String prefix | true | Computed once, cached forever in prod. |
| Callback | true | Runs once on first request, cached forever in prod. |
| Object with cache: true (or omitted) | true | Same as above. |
| Object with cache: false | false | Deep-clones base manifest and transforms on every request. |
Use cache: false only when the transform depends on per-request data (e.g., geo-routing based on request headers). For static CDN prefixes, the default cache: true is recommended.
If you're using the object form with cache: true, you can set warmup: true to compute the transformed manifest in the background at server startup.
transformAssets: {
transform: process.env.CDN_ORIGIN || '',
cache: true,
warmup: true,
}
transformAssets: {
transform: process.env.CDN_ORIGIN || '',
cache: true,
warmup: true,
}
This has no effect in development mode, or when cache: false.
Note: In development mode (TSS_DEV_SERVER), caching is always skipped regardless of the cache setting, so you always get fresh manifests.
If you want to set cross-origin behavior from the app shell instead of the server entry, HeadContent also accepts assetCrossOrigin:
<HeadContent assetCrossOrigin="anonymous" />
<HeadContent assetCrossOrigin="anonymous" />
or:
<HeadContent
assetCrossOrigin={{
modulepreload: 'anonymous',
stylesheet: 'use-credentials',
}}
/>
<HeadContent
assetCrossOrigin={{
modulepreload: 'anonymous',
stylesheet: 'use-credentials',
}}
/>
If both transformAssets and assetCrossOrigin set a cross-origin value, assetCrossOrigin overrides the value from transformAssets.
assetCrossOrigin only applies to manifest-managed modulepreload and stylesheet links, not arbitrary links you return from route head() functions.
transformAssets rewrites the URLs in the SSR HTML - modulepreload hints, stylesheets, and the client entry script. This means the browser's initial page load fetches all assets from the CDN.
However, when users navigate client-side (e.g., clicking a <Link>), TanStack Router lazy-loads route chunks using import() calls with paths that were baked in at build time by Vite. By default, Vite uses base: '/', which produces absolute paths like /assets/about-abc123.js. These resolve against the app server's origin, not the CDN - even though the entry module was loaded from the CDN.
To fix this, set base: '' in your Vite config:
// vite.config.ts
export default defineConfig({
base: '',
// ... plugins, etc.
})
// vite.config.ts
export default defineConfig({
base: '',
// ... plugins, etc.
})
With base: '', Vite generates relative import paths for client-side chunks. Since the client entry module was loaded from the CDN (thanks to transformAssets), all relative import() calls resolve against the CDN origin. This ensures that lazy-loaded route chunks during client-side navigation are also served from the CDN.
Using an empty string rather than './' is important - both produce relative client-side imports, but base: '' preserves the correct root-relative paths (/assets/...) in the SSR manifest so that transformAssets can properly prepend the CDN origin.
| base setting | SSR assets (initial load) | Client-side navigation chunks |
|---|---|---|
| '/' (default) | CDN (via transformAssets) | App server |
| '' | CDN (via transformAssets) | CDN (relative to entry module) |
Tip: base: '' is recommended whenever you use transformAssets so that all assets - both on initial load and during client-side navigation - are consistently served from the CDN.
transformAssets only rewrites URLs in the TanStack Start manifest - the tags emitted during SSR for preloading and bootstrapping the application.
It does not rewrite asset URLs imported directly in your components:
// This import resolves to a URL at build time by Vite
import logo from './logo.svg'
function Header() {
return <img src={logo} /> // This URL is NOT affected by transformAssets
}
// This import resolves to a URL at build time by Vite
import logo from './logo.svg'
function Header() {
return <img src={logo} /> // This URL is NOT affected by transformAssets
}
For these asset imports, use Vite's experimental.renderBuiltUrl in your vite.config.ts:
// vite.config.ts
export default defineConfig({
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === 'js') {
return { relative: true }
}
return `https://cdn.example.com/${filename}`
},
},
})
// vite.config.ts
export default defineConfig({
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === 'js') {
return { relative: true }
}
return `https://cdn.example.com/${filename}`
},
},
})
