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

GitHub

View Github