A modern static site generator for React applications with smart revalidation and hot reloading.
- ⚡ Fast static site generation with React & TypeScript
- 🔄 WebSocket-based hot reloading during development
- 🎯 Smart page revalidation - rebuild specific pages without full rebuilds
- 🛠️ Simple CLI commands
- 📦 Optimized production builds with Vite
- Node.js >= 18.0.0
- npm >= 7.0.0
npm install @bouygues-telecom/staticjs -gcreate-staticjs-app
cd your-project
npm installnpm run devYour site will be available at http://localhost:3000
npm run build
npm run startProduction server runs at http://localhost:3456
static dev- Start development server with hot reloadstatic build- Build static site for productionstatic start- Serve built files in production mode
See CLI_USAGE.md for detailed command documentation.
your-project/
├── src/
│ ├── pages/ # Your pages
│ ├── components/ # Reusable components
│ ├── styles/ # Style files
│ └── revalidate.ts # Custom revalidation handler (optional)
├── _build/ # Generated static files
└── static.config.ts # Configuration
StaticJS is configured via a static.config.ts (or .js / .mjs) file at the root of your project.
// static.config.ts
export default {
PORT: 5678,
CORS_ORIGINS: ["https://example.com"],
CSP_DIRECTIVES: {
scriptSrc: ["https://cdn.example.com"],
},
};| Option | Type | Default | Description |
|---|---|---|---|
PORT |
number |
3456 |
Server port |
NODE_ENV |
string |
"development" |
Environment (development, production, test) |
PROJECT_ROOT |
string |
process.cwd() |
Project root directory |
BUILD_DIR |
string |
"_build" |
Output directory name |
REQUEST_TIMEOUT |
number |
30000 |
Request timeout in ms |
BODY_SIZE_LIMIT |
string |
"10mb" |
Max request body size |
RATE_LIMIT_WINDOW |
number |
900000 |
Rate limit window in ms (15 min) |
RATE_LIMIT_MAX |
number |
100 |
Max requests per window |
REVALIDATE_RATE_LIMIT_MAX |
number |
10 |
Max revalidation requests per window |
REVALIDATE_API_KEY |
string |
"" |
API key for the revalidate endpoint (required in production) |
REVALIDATE_REQUEST_TIMEOUT |
number |
120000 |
Revalidation request timeout in ms (2 min) |
CORS_ORIGINS |
string[] |
[] |
Allowed CORS origins |
TRUST_PROXY |
number | string | string[] |
1 |
Trusted proxy hops / IPs for client-IP detection (see below) |
CACHE_MAX_AGE |
number |
86400 (prod) / 0 (dev) |
Cache max-age in seconds |
HOT_RELOAD_ENABLED |
boolean |
true (dev) |
Enable hot reload |
WEBSOCKET_ENABLED |
boolean |
true (dev) |
Enable WebSocket server |
FILE_WATCHING_ENABLED |
boolean |
true (dev) |
Enable file watcher |
WEBSOCKET_PATH |
string |
"/ws" |
WebSocket endpoint path |
FILE_WATCH_DEBOUNCE |
number |
300 |
File watch debounce in ms |
SUPPRESS_MODULE_DIRECTIVE_WARNINGS |
boolean |
false |
Suppress Vite MODULE_LEVEL_DIRECTIVE warnings |
CSP_DIRECTIVES |
Record<string, string[]> |
{} |
Additional Content Security Policy sources (see below) |
DECODE_TEMPLATE_EXPRESSIONS |
boolean |
false |
Decode HTML entities within {{ }} template expressions (see below) |
StaticJS sets the following CSP defaults via Helmet:
| Directive | Default sources |
|---|---|
defaultSrc |
'self' |
scriptSrc |
'self' |
styleSrc |
'self', 'unsafe-inline', https://assets.bouyguestelecom.fr |
imgSrc |
'self', data:, https: |
Use CSP_DIRECTIVES to add extra trusted sources. Values are merged with the defaults ('self' is always included):
// static.config.ts
export default {
CSP_DIRECTIVES: {
scriptSrc: ["https://assets.bouyguestelecom.fr", "https://cdn.example.com"],
fontSrc: ["https://fonts.gstatic.com"],
connectSrc: ["https://api.example.com"],
},
};This would produce the following CSP headers:
script-src 'self' https://assets.bouyguestelecom.fr https://cdn.example.com;
font-src https://fonts.gstatic.com;
connect-src https://api.example.com;
Any valid CSP directive name can be used (camelCase): defaultSrc, scriptSrc, styleSrc, imgSrc, connectSrc, fontSrc, frameSrc, mediaSrc, workerSrc, etc.
StaticJS sits behind a reverse proxy (Caddy, nginx, AWS ALB, etc.) in most deployments. The TRUST_PROXY setting tells Express how many proxy hops to trust when reading the client IP from the X-Forwarded-For header. This is used by rate limiting and other IP-based logic.
| Value type | Example | Meaning |
|---|---|---|
number |
1 |
Trust the first n proxy hops (default) |
string |
"loopback" |
Trust a named range or subnet (e.g. "loopback", "10.0.0.0/8") |
string[] |
["10.0.0.1", "10.0.0.2"] |
Trust only the listed addresses |
// static.config.ts — behind two reverse proxies
export default {
TRUST_PROXY: 2,
};// static.config.ts — trust a specific subnet
export default {
TRUST_PROXY: "10.0.0.0/8",
};Note: Setting
trust proxytotrue(boolean) is intentionally rejected because it allows trivial bypass of IP-based rate limiting. Use a specific hop count or address instead.
When serving HTML through a template engine like Caddy's Go templates, you may embed expressions like {{ .SomeVar }} or {{ include "/path/file" }} in your React components. However, React's SSR HTML-encodes text nodes, turning " into ", which breaks these template engines.
Enable DECODE_TEMPLATE_EXPRESSIONS to automatically decode HTML entities only within {{ }} blocks:
// static.config.ts
export default {
DECODE_TEMPLATE_EXPRESSIONS: true,
};This decodes the following entities inside template expressions:
"→"&→&<→<>→>'/'→'
HTML attributes outside of {{ }} remain properly encoded.
StaticJS provides two components for handling Go/Caddy template expressions: <ServerOnly> and <ServerElement>. Both prevent React hydration mismatches when Caddy processes template expressions.
| Scenario | Component | Example |
|---|---|---|
| Template as part of mixed text content | ServerOnly |
Hello, <ServerOnly>{'{{ $name }}'}</ServerOnly>! |
| Template as entire element content | ServerElement |
<ServerElement as="h1">{'{{ $title }}'}</ServerElement> |
| Template in element attributes | ServerElement |
serverProps={{ 'aria-label': '{{ $name }}' }} |
Conditional structure ({{ if }}...{{ end }}) |
ServerElement |
Wrap entire conditional block |
// ServerOnly - adds a wrapper span
<Title><ServerOnly>{'{{ $forename }}'}</ServerOnly></Title>
// Output: <h1><span data-server-only>Jean</span></h1>
// ServerElement - no extra wrapper, cleaner DOM
<ServerElement as={Title}>{'{{ $forename }}'}</ServerElement>
// Output: <h1 data-server-element>Jean</h1>Rule of thumb: Use ServerElement when possible for cleaner DOM. Use ServerOnly when you need to mix static content with template expressions.
Best for inline template expressions mixed with static content:
import { ServerOnly } from '@bouygues-telecom/staticjs/server-only';
export default function MyPage() {
return (
<p>
Welcome back, <ServerOnly>{'{{ $forename }}'}</ServerOnly>!
You have <ServerOnly>{'{{ $count }}'}</ServerOnly> messages.
</p>
);
}Props:
children: Content containing Go template expressionsas: Wrapper element tag ('span'or'div', defaults to'span')className: Optional CSS class name for the wrapper element
How it works:
- Server renders:
<span data-server-only>{{ $forename }}</span> - Caddy processes:
<span data-server-only>Jean</span> - Client captures "Jean" from DOM before React hydrates
- React renders with
dangerouslySetInnerHTML- no mismatch!
Note: Content inside <ServerOnly> will not have React event handlers or state.
Best for entire element content, attributes with templates, or conditional structures:
import { ServerElement } from '@bouygues-telecom/staticjs/server-only';
import { Title } from '@trilogy-ds/react';
// Cleaner than wrapping with ServerOnly - no extra span
<ServerElement as={Title} level={1}>
{'{{ $pageTitle }}'}
</ServerElement>
// Output: <h1 data-server-element>My Page Title</h1><ServerElement
as="button"
id="my-button"
serverProps={{
'aria-label': '{{ $forename }}',
'data-user-id': '{{ .User.ID }}',
}}
className="button"
type="button"
>
{'{{ $forename }}'}
</ServerElement>When Go template conditionals change the DOM structure, wrap the entire block:
<ServerElement as="div" id="auth-section">
{'{{ if $isLoggedIn }}'}
<p>Welcome back!</p>
{'{{ else }}'}
<p>Please log in</p>
{'{{ end }}'}
</ServerElement>import { Button } from '@trilogy-ds/react';
<ServerElement
as={Button}
id="user-button"
serverProps={{ 'aria-label': '{{ $forename }}' }}
variant="primary"
>
{'{{ $forename }}'}
</ServerElement>Props:
as: HTML tag or React component (e.g.,'button','div',Button,Title)id: Recommended for reliable client-side matchingserverProps: Object with attributes containing Go template expressionschildren: Element content (can include template expressions directly)- All other props are passed through to the element
How it works:
- Server renders element with template expressions in attributes and content
- Caddy processes all templates
- Client captures the processed attributes and innerHTML from DOM
- React renders with captured values - no mismatch!
Important notes:
- Always provide a stable
idprop for reliable matching - Content inside
ServerElementwon't have React event handlers - For interactive elements, use event delegation on a parent element
Rebuild specific pages without a full rebuild:
curl -X POST http://localhost:3000/revalidate \
-H "Content-Type: application/json" \
-d '{ "paths": ["home", "about"] }'For full control over which pages get rebuilt, create a src/revalidate.ts (or .js / .mjs) file in your project. It must export a default async function that receives the Express request and returns a string[] of page paths:
// src/revalidate.ts
import { Request } from "express";
// Called before rebuild — return the paths to rebuild (empty = all pages)
export async function beforeRevalidate(req: Request): Promise<string[]> {
const res = await fetch("https://my-cms.com/updated-pages");
const pages = await res.json();
return pages.map((p: any) => p.slug);
}
// Called after rebuild — purge CDN, notify a webhook, etc. (optional)
export async function afterRevalidate(req: Request, paths: string[]): Promise<void> {
console.log("Rebuilt:", paths);
}If the file does not exist, StaticJS falls back to req.body.paths. Both exports are optional.
For contributors working on the StaticJS library itself:
npm install
node setup-dev.jsThis sets up the development environment with local package linking.
MIT
Built with ❤️ by Bouygues Telecom