SNext.js
Build static sites with React
Next.js + react-snap = SNext.js
What is this?
This is an experimental library based on new React 18 server capabilities that combine the spirit of Next.js with the idea behinded react-snap of creating a static site by crawling all links.
To do this instead of using puppeter and wait to all request to finsh we use the output of your server side rendered application to crawl the links. Thanks to React 18 we can execute all side effects of React tree using Suspense.
This project is an experimental stage and is based on ogoing React 18 RC apis but by tests seems working like a charm.
Getting started
First of all install snext
yarn add --dev snext
Then add theese scripts to package.json
// ..
"scripts": {
"dev": "snext dev",
"build": "snext build",
"staticize": "snext staticize"
}
// ..
The dev
command wil start a dev server on port 7000 with fast refresh and hot module reload so you can try
both your client and server code.
The build
command generate a intermediate folder with a production build of your client and server code.
The staticize
command use the production code of build
to render your app using node and crawl your links to generate a static site with all of your routes.
Finally your have to configure snext
options, for now only in package.json
, add:
// ..
"snext": {
// Required
"clientEntry": "./src/index.js",
"serverComponent": "./src/StaticApp.js",
"skeletonComponent": "./src/Skeleton.js",
// Optional
// the directory to serve public
"publicDir": "public",
// the port of dev server
"port": 7000,
// the staticize output directory
"outputDir": "build"
}
// ..
The serverComponent
is the path of your component rendered on the server.
In the snext
mental model here you should be able to collect the result of your side effects that happends during render.
The Server Component receive the url
prop from the server.
The Server Component has tow optional special method getStaticProps
used to inject extra props into the component before render and getInitialData
use to “dump” the data collected into a serializable format.
The snext
library try to be more unopinated possible but for clarity we use the react-query library to handle suspense data fetching and the react-router library to handle routing and show a real world example.
import { dehydrate, QueryClient, QueryClientProvider } from 'react-query'
import { StaticRouter } from 'react-router-dom/server'
import App from './App'
// This the app render on the server tipically the inner app performs
// side effect during render both on server and client
// here we can inject the cache provider with a value created from
// server.
// Also here we use the url of server request to render the correc tree.
export default function StaticApp({ queryClient, url }) {
return (
<QueryClientProvider client={queryClient}>
<StaticRouter location={url}>
<App />
</StaticRouter>
</QueryClientProvider>
)
}
// Create the query client on the server
// Tipically you need a mutable cache to write, this is hook
// can be used to create them.
// you can also perform other side effects and return them as props
// (the original Next.js getStaticProps)
StaticApp.getStaticProps = async ({ url }) => {
return {
props: {
queryClient: new QueryClient({
defaultOptions: {
queries: {
cacheTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
refetchIntervalInBackground: false,
refetchOnMount: false,
staleTime: Infinity,
suspense: true,
},
},
}),
},
}
}
// Here as second argument you have the props injected before
// you can use this hook to dump the values write in the cache
// into a serializable format
StaticApp.getInitialData = async ({ url }, { queryClient }) => {
return {
initialData: dehydrate(queryClient),
}
}
The skeletonEntry
is the component used to provide a shell to your server render app.
The skeleton component is rendered after the server component has finish all side effects and we have its output.
The server component props are the appHtml
the html string of Server Component rendered, the entrypoints
are a list of strings of scripts or styles paths generated by the compilation step.
A tipical Skeleton looks like this:
export default function Skeleton({ appHtml, initialData, entrypoints }) {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="/favicon.ico" />
{entrypoints
.filter((e) => e.endsWith('.css'))
.map((e) => (
<link key={e} href={`/${e}`} rel="stylesheet" />
))}
</head>
<body>
<div
id="root"
dangerouslySetInnerHTML={{
__html: appHtml,
}}
/>
</body>
<script
dangerouslySetInnerHTML={{
__html: `window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};`,
}}
/>
{entrypoints
.filter((e) => e.endsWith('.js'))
.map((e) => (
<script key={e} src={`/${e}`} />
))}
</html>
)
}
The clientEntry
is the path for your browser entry point.
Here we use the initial data injected by the Skeleton to hydrate the state of our app.
We complete the example mimic the above Server Component:
import { hydrateRoot } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
hydrate(queryClient, window.__INITIAL_DATA__)
delete window.__INITIAL_DATA__
hydrateRoot(
document.getElementById('root'),
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
)
EXAMPLES
TODO:
- Typescript support
- More css loaders, css modules, sass (?), post css ecc …
- Generic file loader
- Improve error handling developer experience
- Add Helmet example in blog example
- ESM support for node
LICENSE
MIT