SSR & SSG For React Apps
- 📦 Works out of the box with Create React App.
- 🥰 The developer experience you deserve: Fast Refresh, HMR both on client and server.
- 🚀 Use new React Streaming Server Side Rendering architecture.
- ⚙️ Also available for Cloudflare Workers.
There are already Next.js and Remix why i need Pluffa?
- First you can easily add SSR or SSG to an App built with Create React App with minimal effort.
- In second place Pluffa is not a Framework is more a Build Tool. The spirit of Pluffa is to be a Create React App but for server side rendering, your code, your choice ... but without the overhead of configuring all the build environment.
An example Pokedex App with SEO and SSR/SSG using Pluffa with:
- react-router For routing.
- @tanstack/react-query For Suspense Data Fetching.
- react-helmet-async For SEO in Head.
import { useSSRRequest, useSSRData, getScriptsTags } from '@pluffa/ssr'
import { GetServerData } from '@pluffa/node-render'
import {
dehydrate,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { HelmetData, HelmetProvider } from 'react-helmet-async'
import { StaticRouter } from 'react-router-dom/server'
import App from './App'
export default function Server() {
// Get SSR Url of request
const { url } = useSSRRequest()
// Get data from getServerData
const { queryClient, helmetContext } = useSSRData()
// Init providers with data and use the url for SSR Rouring
return (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={queryClient}>
<StaticRouter location={url}>
<App />
</StaticRouter>
</QueryClientProvider>
</HelmetProvider>
)
}
export const getServerData: GetServerData = async ({
// Current SSR Request
request,
// Map of bundler entrypoints such scripts and styles
entrypoints,
}) => {
// On every request create a fresh SSR Environment
// Instance any data fetching store with Suspense support
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
})
// Handle all SEO Head tags during current request
const helmetContext = {} as HelmetData['context']
return {
// Pass to Server Component
data: {
queryClient,
helmetContext,
},
// Inject content into Node / Edge stream before </head> tag close
// Theese callbacks will be called after all Suspense boundaries finish
injectBeforeHeadClose: () =>
// Create a string using the collected result of <Helmet /> SEO rendering
(['title', 'meta', 'link'] as const)
.map((k) => helmetContext.helmet[k].toString())
.join(''),
injectBeforeBodyClose: () =>
// Serialize Suspense data fetching store data collected during rendering
// for client hydratation. This must be insered BEFORE App runtime scripts.
`<script>window.__INITIAL_DATA__ = ${JSON.stringify(
dehydrate(queryClient)
)};</script>` +
// Inject client JS of your React App
getScriptsTags(entrypoints),
}
}
import { Styles, Root } from '@pluffa/ssr/skeleton'
export default function Skeleton() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="/favicon.ico" />
{/* Bundled collected style tags */}
<Styles />
</head>
<body>
<div id="root">
{/*
Render the Server component, if Server component
generate errors don't render anything.
The Skeleton component is always rendered independently from Server component.
*/}
<Root />
</div>
</body>
</html>
)
}
import './index.css'
import ReactDOM from 'react-dom/client'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
import {
QueryClientProvider,
QueryClient,
hydrate,
} from '@tanstack/react-query'
import { HelmetProvider } from 'react-helmet-async'
// Create client Suspense store
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
})
// Hydrate the store from SSR Data
hydrate(queryClient, (window as any).__INITIAL_DATA__)
// Let Garbage Collector free SSR Data
delete (window as any).__INITIAL_DATA__
// Hydrate SSR React HTML tree
ReactDOM.hydrateRoot(
document.getElementById('root')!,
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</HelmetProvider>
)
import { Suspense } from 'react'
import { Helmet } from 'react-helmet-async'
import { Route, Routes } from 'react-router-dom'
import Pokedex from './Pokedex'
import Pokemon from './Pokemon'
export default function App() {
return (
<>
<Helmet>
<title>Pokedex</title>
</Helmet>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route index element={<Pokedex />} />
<Route path="/pokemon/:name" element={<Pokemon />} />
</Routes>
</Suspense>
</>
)
}
// Create an isomorphic http client
// You can use library such AXIOS that alredy have two different export
// for web and node.
// We use fetch to show an example with minimal runtime overhead.
// ... You can also use this technique to polyfill fetch by setting global.fetch
// in NodeJS env ...
const fetch =
// Special Pluffa value populated at BUILD time
// So bundler can strip code in branches
process.env.IS_PLUFFA_SERVER
? // On the server we use the undici fetch implementation
require('undici').fetch
: // On the client use built it window fetch
window.fetch
export default fetch as typeof window.fetch
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import fetch from './fetch'
interface Pokemon {
name: string
}
interface PokemonList {
results: Pokemon[]
}
// Call the same api on client and server
// with pokemons information
export default function Pokedex() {
const { data } = useQuery(['pokemons'], () =>
fetch(`https://pokeapi.co/api/v2/pokemon`).then(
(r) => r.json() as Promise<PokemonList>
)
)
return (
<div>
<h1>Pokedex</h1>
{data!.results.map((pokemon) => (
<div key={pokemon.name}>
<Link to={`/pokemon/${pokemon.name}`}>
<h2>{pokemon.name}</h2>
</Link>
</div>
))}
</div>
)
}
import { useQuery } from '@tanstack/react-query'
import { Helmet } from 'react-helmet-async'
import { Link, useParams } from 'react-router-dom'
import fetch from './fetch'
interface PokemonDetail {
name: string
sprites: {
back_default: string
front_default: string
}
}
// Use router params to render a speicific pokemon
export default function Pokemon() {
const { name } = useParams()
const { data: pokemon } = useQuery(['pokemon', name], () =>
fetch(`https://pokeapi.co/api/v2/pokemon/${name}/`).then(
(r) => r.json() as Promise<PokemonDetail>
)
)
return (
<div>
{/* Some SEO of our Pokemon */}
<Helmet>
<title>{`${pokemon!.name} Pokedex`}</title>
</Helmet>
<h1>{pokemon!.name}</h1>
<h2>
<Link to="/">{'<'}</Link>
</h2>
<img src={pokemon!.sprites.back_default} />
<br />
<img src={pokemon!.sprites.front_default} />
</div>
)
}
First install the main Pluffa package.
Yarn:
yarn add --dev pluffa
NPM:
npm install --save-dev pluffa
Then install the runtime related package. The default runtime for Pluffa is node.
Yarn:
yarn add --dev @pluffa/node
NPM:
npm install --save-dev @pluffa/node
First of all your need to update your package.json
file to configure the Pluffa
commands:
"scripts": {
"dev": "pluffa dev",
"start": "pluffa start",
"build": "pluffa build",
"staticize": "pluffa staticize"
}
An overview of commands:
Starts a dev server on port 7000 with hot reload and fast refresh.
Build your app for production.
This command must be runned after the build command. Starts a production server on port 7000.
This command must be runned after the build command. Perform the Static Site Generating of your app.
Then you need at least 3 key configuration: skeletonComponent, serverComponent and clientEntry.
There are a lot of way to configure Pluffa, but with start with the basic one.
The pluffa.json
near to package.json
file:
{
"$schema": "https://cdn.giova.fun/pluffa/schema.json",
"runtime": "node",
"skeletonComponent": "./src/Skeleton.js",
"serverComponent": "./src/Server.js",
"clientEntry": "./src/index.js"
}
Path to your skeleton React component file. The default export of this file is used as skeleton component. This component is rendered only on the server and describe the shell of your React application.
import { Root, Scripts, Styles } from '@pluffa/ssr/skeleton'
export default function Skeleton() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="/favicon.ico" />
<Styles />
</head>
<body>
<div id="root">
<Root />
</div>
<Scripts />
</body>
</html>
)
}
To write your skeleton component you can use the pre installed '@pluffa/ssr/skeleton'
package.
This package contains the building block for your skeleton component.
Path to your server React component file. The default export of this file is used as server component. This component is rendered only on the server and describe the root tree of your React app.
The server component has a super power that is the key feature of Pluffa. It wait all the suspense boundaries to finish, so you can use it to do Server Side Rendering with Suspense.
import App from './App'
export default function Server() {
return <App />
}
The server component alone isn't so special...
But the server component file can also export a special function use to
configure the SSR called getServerData
.
Signature:
export interface ServerData<Data> {
data: Data
injectBeforeBodyClose?: () => string
injectBeforeHeadClose?: () => string
}
export interface GetServerDataConfig {
request: RequestWrapper
entrypoints: Record<string, string[]>
}
export type GetServerData<Data> = (
config: GetServerDataConfig
) => ServerData<Data> | Promise<ServerData<Data>>
The getServerData
is called on each request so you can create safe contexts
for the your SSR infrastructure.
You can access the data
field in your server component with the useSSRData
hook via '@pluffa/ssr'
pre installed package.
import App from './App'
import { useSSRData } from '@pluffa/ssr'
export default function Server() {
const { foo } = useSSRData()
return <App foo={foo} />
}
export const getServerData = async () => {
const foo = await getFoo()
return {
data: {
foo,
},
}
}
If you start from scratch with Pluffa you can create a blank App with:
yarn create pluffa-app YourAppFolder
or
npx create-pluffa-app YourAppFolder
You can also specify a --template
option, availables are:
- node: Base SSR Pluffa node template.
- node-typescript: Base SSR Pluffa node template but with TypeScript.
You can configure Pluffa in a lot of way:
In the package.json with a "pluffa"
key.
A pluffa.json file. To have autocomplete in your editor you can use
the special "$schema"
key:
{
"$schema": "https://cdn.giova.fun/pluffa/schema.json"
}
You can also use a JavaScript file pluffa.config.js
for CommonJS, the default
exports is used as configuration:
/**
* @type {import('@pluffa/node/config').NodeConfig}
*/
module.exports = {
/* Config Here */
}
Or a pluffa.config.mjs
file for ESM format:
/**
* @type {import('@pluffa/node/config').NodeConfig}
*/
export default {
/* Config Here */
}
Finally if you need to customize you Pluffa config based on wich command is runned you can export a function that return the configuration or a configuration Promise:
/**
* @param {import('pluffa/config').CommandName} cmd
* @return {Promise<import('@pluffa/node/config').NodeConfig>}
*/
export default async (cmd) => {
return {
/* Config Here */
}
}
You can check the configuration methodo picked by inspecting the Pluffa output in your terminal.
MIT