Introduction
In the React ecosystem, Next.js stands as the representative SSR framework, beloved by developers for its out-of-the-box experience and powerful features. But for those who want complete control over the build process, deep customization of deployment strategies, or simply understanding SSR fundamentals, Next.js’s “black box” nature becomes a limitation.
I spent several months building a lightweight yet fully-featured React SSR framework from scratch — NSBP (Node React SSR by Webpack). Here’s the development journey, technical choices, core features, and practical experience gained.
Live Demo: https://nsbp.erishen.cn/ | GitHub: https://github.com/erishen/nsbp
Why Build Instead of Using Next.js?
Complete Control Over the Build Process
Next.js’s power comes from automation, but that means losing some control:
// Next.js build (black box)
next build
// → auto routing, auto code splitting, auto image optimization, hundreds of hidden steps
// NSBP — precise control over every step
// config/webpack.base.js
module.exports = {
entry: {
client: './src/client/index.tsx',
server: './src/server/index.ts'
},
plugins: [
new MiniCssExtractPlugin(),
new TerserPlugin(),
// add any Webpack plugin
]
}
Deep Customization of Deployment
Next.js’s Vercel deployment is convenient, but if you want to deploy on your own Linux server, use Docker Compose, configure reverse proxies and load balancing, or implement custom security policies — controlling your own Express server is advantageous.
Learning SSR Fundamentals
Writing SSR rendering logic by hand gives you deep understanding:
const sheet = new ServerStyleSheet()
const jsx = sheet.collectStyles(
<Provider store={store}>
<StaticRouter location={reqPath}>
<Routes>{/* ... */}</Routes>
</StaticRouter>
</Provider>
)
const content = renderToString(jsx)
const styleTags = sheet.getStyleTags()
Technical Stack & Architecture
| Category | Choice | Version | Reason |
| React | React 19 | 19.2.3 | Latest, concurrent features, better type support |
| State | Redux Toolkit | — | Simplified Redux, built-in Thunk support |
| Router | React Router DOM | 7.12.0 | Stable client-side routing |
| Code Split | @loadable/component | 5.15.0 | Mature code splitting |
| Build | Webpack 5 | 5.96.0 | Modular, extensible, rich ecosystem |
| Server | Express | 5.2.1 | Lightweight, rich middleware ecosystem |
| Styling | Styled-components + Sass/Less | — | Multiple choices, flexible switching |
| Language | TypeScript | 5.x | Type safety, excellent DX |
SSR Rendering Architecture
HTTP Request
↓
Express Middleware (Helmet, Rate Limit)
↓
SSR Render Layer:
1. Create Redux Store
2. Prefetch route data (loadData)
3. renderToString → HTML
4. Extract Styled-components styles
5. Serialize state to window.context
↓
Return HTML + Initial State
Client Hydration
const App = () => {
const store = useMemo(() => {
if (isSEO() && window?.context?.state) {
return getStore(window.context.state)
}
return getStore()
}, [])
return (
<Provider store={store}>
<BrowserRouter>
<Routes>{/* ... */}</Routes>
</BrowserRouter>
</Provider>
)
}
loadableReady(() => {
hydrateRoot(document.getElementById('root')!, <App />)
})
Core Features
Three Rendering Modes
| Mode | URL | Use Case |
| SSR (default) | / or /?nsbp=1 |
SEO, fast initial load |
| CSR | /?nsbp=0 |
Debugging, dev experience |
| SSR Fallback | /?nsbp=1&from=link |
Internal links, allow client update |
Smart SSR Data Prefetching
Server-side data prefetch to avoid client-side re-requests:
// src/Routers.tsx
export default [{
path: '/',
element: <Home />,
loadData: homeLoadData, // ← prefetch on SSR
key: 'home'
}]
Performance comparison: Traditional CSR ~2-3 seconds; NSBP SSR ~0.5-1 second.
Code Splitting & Lazy Loading
import loadable from '@loadable/component'
const Home = loadable(() => import('@containers/Home'), { fallback: <Loading /> })
Initial load: ~200KB main bundle, only home page code. Other pages loaded on demand.
Multi-Layer Security
– Helmet security headers: CSP, X-Frame-Options, X-Content-Type-Options, HSTS
– Rate limiting: 100 requests per 15 minutes on API routes
Docker One-Click Deployment
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
ports: ["8081:3001"]
environment: [NODE_ENV=production, ENABLE_RATE_LIMIT=1]
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get(...)"]
Developer Experience
– Quality toolchain: ESLint + Prettier + Husky pre-commit hooks with lint-staged
– CLI tool: npx nsbp-cli create my-app for project scaffolding
– Make commands: make dev, make prod, make logs, make shell
Performance Optimizations
– Tree shaking: usedExports: true, sideEffects: false
– Code minification: TerserPlugin with multi-process compression, drop_console in production
– Static caching: 1-year immutable cache for JS/CSS/images
– Image dimension probing: probe-image-size during SSR to avoid client re-requests
When to Use (and Not Use) NSBP
Good for: personal projects, rapid prototypes, education/learning, highly customized projects, low-resource environments
Not ideal for: large enterprise apps, rapid iteration projects, AI/ML features
Problems Solved
– React 19 Hydration Error #418: Replaced dangerouslySetInnerHTML scripts with useEffect
– Docker port config: Moved docker-compose.yml to root directory for .env access
– Webpack Dev Server hot reload: Correctly configured Browser Sync Webpack Plugin
Future Plans
React Server Components, tRPC integration, Vitest migration, Service Worker caching, CDN support, ISR implementation
Conclusion
Building NSBP was an invaluable learning experience. While it may not replace Next.js, I deeply understood every link in modern frontend engineering, mastered building complete applications from scratch, and developed problem-solving patterns for complex systems.
Framework choice has no absolute right or wrong — only suitability. Next.js suits rapid development and most scenarios, but if you want complete control, deep learning, or special requirements, building from scratch is also a valid choice.