Experimental. Render parts of a Next.js application (e.g. Header & Footer) as standalone bundles for embedding into third-party applications. Backed by Vite for the build, Next.js for the runtime API route.
yarn add @freshcells/next-static-components vite @vitejs/plugin-react-swc
next≥ 16vite≥ 8 (uses rolldown)@vitejs/plugin-react-swc≥ 4node≥ 18react,react-dom≥ 18 (the build uses your hoisted React, not Next.js's bundled copy)
-
Add
.next-static/to.gitignore. -
Create
next-static.config.mjsin your project root (next tonext.config.mjs). -
Add scripts:
{ "scripts": { "build-static": "next-static-components", "build-static-dev": "next-static-components dev" } } -
Create a catch-all API route (
pages/api/static/[...slug].tsor App Router equivalent) wired toserve().
The CLI loads next-static.config.mjs (or .js) from the directory you run it in. defineConfig is just an identity helper for IDE typing — the file can also export a plain object.
// @ts-check
import { defineConfig } from '@freshcells/next-static-components'
export default defineConfig({
entry: './static-page/entrypoint.tsx',
importExcludeFromClient: ['../graphql-cache'],
cssExtendFolders: ['../../../packages/shared/styles/extend'],
alias: [
{ find: '~fonts', replacement: './src/fonts' },
{ find: '~@images', replacement: './src/images' },
],
additionalData: `$icomoon-font-path: '~fonts/iconfont/fonts';`,
ssrExternal: ['i18n-iso-countries'],
})| Option | Type | Description |
|---|---|---|
entry |
string (required) |
Path to your entrypoint file (the @main module), relative to project root. |
importExcludeFromClient |
string[] |
Specifiers replaced with an empty module on the client build only. The SSR build keeps the real implementation. Use for server-only code that the client never needs (e.g. graphql codegen output). |
cssExtendFolders |
string[] |
Folders that mirror node_modules. For each .scss file imported from node_modules/<pkg>/<path>.scss, if <extendFolder>/<pkg>/<path>.scss exists it gets appended via @import. Equivalent to the webpack-css-import-inject-loader chain. |
alias |
{find, replacement}[] |
Extra import + CSS url() aliases. find may be a string or RegExp. Webpack-style ~pkg references are stripped automatically — you only need entries here for project-specific paths like ~fonts, ~@images. Relative replacements are resolved against the project root. |
additionalData |
string |
Raw SCSS prepended to every Sass entry. Concatenated after the consumer's next.config.sassOptions.additionalData, so this is where project-specific variable overrides go (e.g. $icomoon-font-path: '...';). |
ssrExternal |
string[] |
Extra packages to mark as external on the SSR build. The defaults (next, react, react-dom, react-dom/server) are always external; add packages that fail to bundle (typically dynamic-require() deps like i18n-iso-countries). |
whitelabelBaseFolder |
string |
Base folder containing whitelabel themes, relative to project root. Defaults to src/whitelabels. See Whitelabels. |
The build reads these fields from your existing next.config.{mjs,js,cjs,ts} so you don't have to duplicate them:
i18n— locales, defaultLocale, domainsbasePathexperimental.swcPlugins— fed to@vitejs/plugin-react-swcsassOptions.additionalData— string prepended to every Sass entrysassOptions.loadPaths— Sass@importresolution rootssassOptions.silenceDeprecations— Sass deprecation warning filter
There are no in-package defaults for these — Sass behavior matches whatever your next.config.mjs already declares. Override or add anything else via next-static.config.mjs.
next-static-components # production build
next-static-components dev # watch + dev React
| Flag | Description |
|---|---|
--dev |
Force development React (unminified errors + dev assertions on the client). Implied by the dev subcommand. |
--cacheSuffix=<name> |
Use .next-static/cache/vite-<name> as the Vite cache directory. Useful for parallel build variants. |
The dev subcommand runs vite build --watch for both client and SSR. Output filenames are stable (no hashes) and unminified, with inline JS sourcemaps. Rebuilds are picked up by the next request to /api/static/render — no Next.js dev-server restart, just a browser refresh.
Set WHITELABEL=<name> to build a whitelabel variant.
Note: In general this is an escape hatch for advanced use cases. Better alternatives are using e.g. css variables / runtime / e.g. dynamic imports etc.
WHITELABEL=xyz next-static-components
A theme directory at <whitelabelBaseFolder>/<name> (default src/whitelabels/<name>) mirrors the src/ tree. Every file it contains replaces its src/ counterpart at build time, with two exceptions:
styles/— not file-swapped; instead the folder is prepended to the SassloadPaths, so theme partials win over base partials. (Sass reads@use/@importtargets straight from disk, a content swap can't intercept them.)translations/— skipped entirely (merged at runtime by the consuming app).
The swap keeps the original module id: relative imports inside an override resolve against the original src/ location, not the theme directory. Reference theme-local assets via an alias (e.g. a @whitelabels/* tsconfig path — tsconfig paths are resolved automatically).
Notes:
- Output still goes to
.next-static/— one whitelabel per build; switching requires a rebuild. Only the Vite cache dir is keyed per whitelabel. - In watch mode, edits to existing override files trigger rebuilds, but adding a new override file requires restarting the watcher (the override set is scanned at startup).
A single file declares all components, props, optional wrapper, and head content.
import React from 'react'
import type { Entrypoint, WrapperProps } from '@freshcells/next-static-components'
interface Context {
someData: string
}
interface Props {
someData: string
}
const Header = (props: Props) => <p>My Header</p>
const Footer = (props: Props) => <p>My Footer</p>
const entry: Entrypoint<Props, Context> = async (context) => ({
props: context,
components: [Header, Footer],
wrapper: function Wrapper({ components }: WrapperProps) {
const [header, footer] = components
return (
<div>
{header}
<div>something in between</div>
{footer}
</div>
)
},
additionalHeadElement: <title>A title</title>,
})
export default entry// pages/api/static/[...slug].ts
import { serve } from '@freshcells/next-static-components'
export default serve(async (req, res) => ({ someData: 'myValue' }), {
assetPrefix: 'https://your-cdn.example.com',
linkPrefix: 'https://your-main-domain.example.com',
locale: 'de-de',
})| Option | Description |
|---|---|
assetPrefix |
URL prefix for emitted assets (CDN host). Empty = relative URLs. |
linkPrefix |
URL prefix used by useRouter().push() and link generation. |
locale |
Locale to render. Falls back to next.config.mjs's defaultLocale. |
outputMode |
'html' (default), 'jsonp', or (req, res, { styles, head, content, scripts }) => void for embedding into another framework's response. |
The second argument can also be a function — useful when options depend on req:
export default serve(
async (req) => ({
/* context */
}),
async (req) => ({
locale: (req.query.locale as string) ?? 'en-gb',
linkPrefix: 'https://some-domain.example.com',
}),
)Run yarn build-static, start Next.js (yarn dev or yarn start), and navigate to /api/static/render.
Two terminals:
yarn build-static-dev # rebuilds client + SSR on every save
yarn dev # Next.js dev server (handles the API route)
Edit a .tsx / .scss → terminal A reports built in Xms → refresh the browser. SSR-side changes are picked up on the next request without restarting Next.js (mtime-keyed module reload).
process.env.IS_NEXT_STATIC_BUILD === '1' is set during the build and dev watcher. Branch on it from next.config.mjs if needed.
The client-side Next.js router singleton is not initialized. useRouter() returns a context-backed mock with read-only properties (route, pathname, query, locale, …) and a push() that performs a full navigation via location.href.
import Router from 'next/router' // ❌ no-op default export
import { useRouter } from 'next/router' // ✅Runtime head modification is not supported. Use additionalHeadElement on the entrypoint to inject head content at SSR time.
Backed by React.lazy + Suspense. Streaming SSR (renderToPipeableStream with onAllReady) waits for every Suspense boundary, so the rendered HTML contains the resolved content.
Only dynamic as the import name is supported:
import dynamic from 'next/dynamic' // ✅The build instruments every dynamic(() => import('./X')) callsite to record which lazy boundaries actually rendered, so the SSR HTML preloads only the chunks that streamed (no FOUC, no shipping CSS for unrendered branches).
Shimmed. Hrefs are fully resolved at render time from the serving options — locale prefixes, domains / linkPrefix, and basePath — identically on the server and the client, so embedded pages need no Next.js runtime for correct link targets. Navigation is a native full-page load.
prefetch,replace,scroll,shalloware accepted but no-ops.legacyBehavior/passHrefare not supported; children always render inside the shim's own<a>.onClickandonNavigatework;onNavigate'spreventDefault()cancels the navigation.useLinkStatus()always returns{ pending: false }(same as the Pages Router).
Image-file imports return { src, width, height, blurDataURL } — the StaticImageData shape next/image expects. The next/image component wraps the real one, which is bundled into both the SSR and client bundles.
The SSR bundle (node-main.mjs) carries everything it needs — its only runtime next import is next/dist/shared/lib/router-context.shared-runtime, which must resolve to the host process' copy (shared context identity) and is traced through your API route. Consumers deploying traced/standalone images need no outputFileTracingIncludes workarounds for next/*. Note that passing ssrExternal: ['next'] would reintroduce runtime next imports and break traced deployments.