iTranslated by AI
FUNSTACK Static: A Serverless React Framework
Hello everyone. In this article, I would like to introduce FUNSTACK Static, a new React framework I have recently developed.
Overview of FUNSTACK Static
FUNSTACK Static is a framework specialized for creating "good old SPAs" that do not require a server for React and can be deployed to static file servers.
Despite this, it features the ability to fully leverage optimizations using React Server Components (RSC).
As explained in my previous article, because it is called "React Server Components," people often assume you must deploy a server. However, this is not actually the case. There is a way to enjoy the benefits of RSC while running on static hosting without setting up a server.
Existing frameworks like Next.js also support such modes. However, existing frameworks have features to run servers, and the static hosting mode is often an afterthought.
Furthermore, it is often explained that there are limitations when not running a server. This is natural since you only get partial benefits of RSC when there is no server, but once you are told there are limitations, you get the impression that it is a special mode or something not to be used actively.
In contrast, FUNSTACK Static is designed on the premise of not setting up a server from the start, and aims to fully utilize the benefits of RSC within those constraints.
Even if the high-level implementation is the same, for those who do not want to set up a server, I want to provide an option with a positive impression: "Optimizable with RSC while maintaining the direction of a traditional SPA," rather than a negative perception that "I have to use some limited mode of a mysterious framework that talks about setting up a server."
If you ask whether the internals are the same, they are not. As I will explain in this article, FUNSTACK Static is not exactly the same as existing frameworks. It has a quite unique feel that aligns with the traditional SPA development experience. Please give it a try if you are interested.
Usage
For details, please refer to the documentation (this documentation is, of course, built as a SPA using FUNSTACK Static).
Although FUNSTACK Static calls itself a framework, it has no proprietary CLI; it is essentially a Vite plugin. Therefore, the commands used during development are the same Vite commands like vite dev and vite build. It is a positive point that the experience is consistent with traditional SPA development.
To use FUNSTACK Static, first create a Vite project and add the FUNSTACK Static plugin to it. As I will explain later, you need to provide the names of the two entry points as options.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import funstackStatic from '@funstack/static';
export default defineConfig({
plugins: [
funstackStatic({
root: "./src/Root.tsx",
app: "./src/App.tsx",
}),
react(),
],
});
Currently, you need to specify the two entry points root and app as fixed values, but this might become more flexible in future updates.
Root Entry Point
The Root entry point exports a component that represents the entire HTML document. In its simplest form, it would look like this.
import React from 'react';
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<meta charset="utf-8">
<title>My FUNSTACK Static App</title>
</head>
<body>
{children}
</body>
</html>
);
}
This Root component has the following characteristics:
- During execution,
childrencontains the output of the App entry point. - Root is a Server Component, allowing file reading and asynchronous processing.
- It cannot use Client Components.
The last point is unique and is a constraint specific only to Root. To be precise, you can import and use Client Components, but they will not be hydrated.
Instead, the Root component is completely converted into static HTML at build time.
App Entry Point
This is the component that serves as the entry point for the SPA. It is rendered as the children of the Root component described above.
import React from 'react';
export default function App() {
return (
<div>
<h1>Welcome to my FUNSTACK Static App!</h1>
</div>
);
}
App is also a Server Component. Within the App component or below, you can import and use Client Components.
// Example: src/Counter.tsx
'use client';
import React, { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button type="button" onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
import React from 'react';
import { Counter } from './Counter';
export default function App() {
return (
<div>
<h1>Welcome to my FUNSTACK Static App!</h1>
<Counter />
</div>
);
}
In other words, as long as you are mindful of the distinction between Server Components and Client Components, you can build your application under App with almost the same feeling as traditional SPA development.
Benefits of using RSC
You might think, "If I want to create an SPA with the same feeling as before, I don't need to distinguish between Server Components and Client Components."
However, I am promoting RSC to this extent because there are benefits. From the perspective of building an SPA, there are two main benefits: "the convenience of being able to run code at build time" and "performance improvement by reducing the burden on the client."
Convenience of being able to run code at build time
It is common to want to manage some application behavior via configuration files. Traditionally, you would have worked hard to pack settings into webpack.config.js or vite.config.ts, right?
With RSC, you can freely read and process configuration files as part of your application code.
For example, you can control the behavior of your application by reading config.json as follows. You can directly import JSON files, and you can also read files using the fs module.
import React from 'react';
import config from './config.json';
import fs from 'node:fs/promises';
export default async function App() {
const extraData = await fs.readFile('./extra-data.txt', 'utf-8');
return (
<div>
<h1>{config.title}</h1>
<p>{extraData}</p>
</div>
);
}
Instead of setting up complex build steps, just write normally in JavaScript and incorporate it naturally into a Server Component. This is the modern way to build SPAs.
Performance improvement by reducing the burden on the client
Another benefit of RSC is "performance improvement." This stems from the nature of RSC: "the source code of Server Components is not included in the client bundle, and the client only loads the rendering results."
First, since the source code of Server Components is not sent to the client, there is a possibility that the bundle size will be smaller. However, since the "rendered HTML" is sent to the client instead, the impact on bundle size may not be significant depending on the contents of the component.
Second, since Server Components are not executed on the client side, the CPU load on the client is reduced. The cost for React to render "plain HTML" is lower than the cost of executing components to render them.
By practicing the RSC principle of making only the parts that truly need to be Client Components into Client Components, you can keep the client load low.
What FUNSTACK Static lacks
The constraints of FUNSTACK Static as a framework are only those explained earlier. (Although I might be able to increase the entry points in the future), other than the rule of "specifying two entry points: root and app," there are no particular rules as a framework, aside from the rules of RSC itself.
FUNSTACK Static also does not have a router. There are no file naming conventions at all. If routing is required as an SPA, you would use React Router or an existing SPA router library. By the way, the documentation site mentioned above uses FUNSTACK Router (advertisement).
Indeed, other than the point that you can apply optimizations by RSC, you can use the same method as traditional SPA development.
Also, naturally, FUNSTACK Static does not have the ability to run a server (there is, of course, a Vite development server used during development). Therefore, features of RSC that require a server cannot be used. Specifically, Server Actions are not supported at all.
Oh, the RSC vulnerability that caused a stir a while ago also does not exist in FUNSTACK Static. Because there is no server.
How FUNSTACK Static works
The feature of FUNSTACK Static lies in its build artifacts.
Traditional SPA build artifacts are "entry point HTML files," "bundled JS files," and "other assets such as CSS."
In FUNSTACK Static, all these artifacts are also generated. In addition to that, a feature is that the RSC payload is emitted to files. An RSC payload is data representing the rendering results of Server Components; by having the client read this, it displays the rendering results of Server Components on the browser (specifically, since the RSC payload contains "static HTML" and "calls to Client Components," these are processed by React on the client side).
| Behavior on the client | |
|---|---|
| Traditional SPA | JavaScript bundle → (rendering) → DOM |
| FUNSTACK Static | JavaScript bundle + RSC payload → (rendering) → DOM |
In other words, the benefits of RSC optimization are nothing more than taking what was previously all a "JavaScript bundle," optimizing a portion (Server Components), separating it as text called an RSC payload, and making the JavaScript bundle smaller to reduce the burden on the client.
For example, the following is the actual build artifact structure of the documentation site.
public
├── FUNSTACK_Static_Hero_small.png
├── assets
│ ├── app-CrYJb6f5.js
│ ├── app-ovZqc1Hu.css
│ ├── app-pIc0I6Uv.css
│ ├── index-uNof5X04.js
│ ├── root-BLNqTaRx.css
│ └── rsc-ULMM1pWX.js
├── funstack__
│ ├── index.txt
│ └── rsc
│ └── fun:rsc-payload
│ ├── 29d259c01c528dda.txt
│ ├── 5b6e76f6acd28837.txt
│ ├── 6294448bd9a6bca0.txt
│ └── 6cd1c898f4fb6194.txt
└── index.html
Under assets are the traditional client JavaScript/CSS asset groups. Apart from that, txt files are output under funstack__, which are the RSC payloads.
If you actually open a site built with FUNSTACK Static and check the network tab in developer tools, you can see that these files are being loaded.
And the originality of FUNSTACK Static as a framework is condensed into the part that outputs this RSC payload. Other than that, it mostly utilizes @vite/plugin-rsc, which is the fruit of our predecessors' efforts.
The defer() API
Besides the Vite plugin (funstackStatic()) introduced at the beginning, FUNSTACK Static currently provides one API that can be used within an application. That is defer().
// Since it is an API that can be used from Server Components, it is provided as @funstack/static/server
import { defer } from '@funstack/static/server';
From here, I will explain what defer is and why it is necessary.
Overview of defer
In a word, defer can be thought of as "lazy() for Server Components." lazy() is a React API that existed before RSC, and by using it in combination with Suspense, it achieves lazy loading of Client Components.
import React, { Suspense, lazy } from 'react';
const SomeComponent = lazy(() => import('./SomeComponent'));
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
{/* SomeComponent is loaded lazily */}
<SomeComponent />
</Suspense>
);
}
In particular, by using it in combination with dynamic import (import()), chunk splitting occurs due to the bundler. This is a common performance optimization technique in traditional SPAs that can reduce the size of the JavaScript bundle required at the initial load.
defer was born as a result of applying the same "chunk splitting" concept not to the JavaScript bundle, but to the RSC payload.
Need for splitting RSC payloads
As mentioned above, an RSC payload is data that represents the rendering result of Server Components.
And FUNSTACK Static has only one entry point called App (apart from root). In other words, an application is essentially just <App />.
Then, one RSC payload is obtained as the rendering result of <App />.
This means that without defer, a "single RSC payload containing the entire application's content" is created.
No matter how much optimization there is by RSC, this is a situation close to not using lazy at all, so as expected, the waste is large. For example, consider the following simple routing.
// src/Router.tsx
"use client";
interface RouterProps {
homeContents: React.ReactNode;
aboutContents: React.ReactNode;
}
export const Router: React.FC<RouterProps> = ({ homeContents, aboutContents }) => {
const location = useLocation();
switch (location.pathname) {
case "/":
return <div>{homeContents}</div>;
case "/about":
return <div>{aboutContents}</div>;
default:
return <div>Not Found</div>;
}
};
import { Router } from './Router';
import { HomePage } from './HomePage';
import { AboutPage } from './AboutPage';
export default function App() {
return (
<Router
homeContents={<HomePage />}
aboutContents={<AboutPage />}
/>
);
}
This code is written utilizing the basic idea of making as much as possible into Server Components. Since the actual routing requires the current URL, the Client Component Router is responsible for it.
However, the content of the pages is handled by the Server Components HomePage and AboutPage. Since you can pass Server Components (as JSX Elements) to the props of a Client Component in this way, it is possible to divide the responsibilities such that "the routing logic is left to the Client Component, while the page content is implemented with Server Components."
What becomes a problem in this case is the size of the RSC payload. The rendering result of App will contain the content of both HomePage and AboutPage.
In other words, if you open this SPA, no matter which screen you open, you will load "the HTML of the rendering results of both pages" as the RSC payload. This makes the RSC payload unnecessarily large, and the benefits of RSC are diminished.
Splitting RSC payloads with defer
In fact, the type of defer is as follows.
function defer(element: React.ReactElement): React.ReactNode;
Applying defer to the code above results in the following.
import { defer } from '@funstack/static/server';
import { Router } from './Router';
import { HomePage } from './HomePage';
import { AboutPage } from './AboutPage';
export default function App() {
return (
<Router
homeContents={defer(<HomePage />)}
aboutContents={defer(<AboutPage />)}
/>
);
}
That is, instead of rendering elements like <HomePage />, you can do defer(<HomePage />).
When defer is used, it has the effect that Server Components rendered by defer are separated as distinct RSC payloads. In the list of build output files I showed earlier, having multiple txt files under fun:rsc-payload is the result of this splitting by defer.
public
├── funstack__
│ ├── index.txt
│ └── rsc
│ └── fun:rsc-payload
│ ├── 29d259c01c528dda.txt ←
│ ├── 5b6e76f6acd28837.txt ←
│ ├── 6294448bd9a6bca0.txt ←
│ └── 6cd1c898f4fb6194.txt ←
And the result of defer is replaced by a Client Component like the following. (The following is not actual code, but indicates the rough idea).
"use client";
const DeferredComponent = ({ moduleId }) => {
const rscPayload = getRSCPayloadStream(moduleId);
return use(rscPayload)
};
defer renders the given Server Component into a separate RSC payload and issues an ID for that payload. The result of defer becomes a Client Component like <DeferredComponent moduleId={...} />.
When this is actually executed on the client side, the corresponding RSC payload is acquired from the network for the first time when this DeferredComponent is rendered (that is, when the defer(...) part is actually rendered).
This is indeed similar to lazy() behavior. The difference is that it lazy loads not the "Client Component code," but the "RSC payload, which is the rendering result of the Server Component."
By the way, due to its nature, defer(...) needs to be wrapped in Suspense. It is inevitable since it renders the result loaded from the network.
Conclusion
FUNSTACK Static is a new React framework that allows you to apply optimizations by RSC without significantly changing the experience of traditional SPA development.
defer is its core and a unique feature of FUNSTACK Static. The experience of dynamically calling defer within a program to split RSC payloads should be familiar to those who have used lazy() traditionally.
If you are interested, please send us feedback, impressions, or support.
Supplement
Although I created and published a framework with the concept of "RSC without setting up a server," I am not actually against setting up a server for React. Rather, I think running by setting up a server if it is necessary for performance is a powerful option.
Nevertheless, as written in the body, I made this because I thought there could be an option for people who don't want to set up a server to still utilize RSC.
Adopting a framework based on the premise of not setting up a server also means closing the possibility of setting up a server in the future (though it might be possible if you work hard to migrate, it is difficult). It is quite conceivable that not setting up a server becomes technical debt.
I once reached out to Service Workers to improve the performance of SPAs without servers. It wasn't the common kind that just caches static files, but one with solid logic loaded. The benefits of not having to set up a server are not small, but whether it was better than implementing and maintaining a Service Worker hard or just setting up a server is not that self-evident. Well, there were no RSCs back then.
Based on these points, please make use of FUNSTACK Static as one option.
Finally, this was a supplementary note that may throw cold water, but I think the concept of FUNSTACK Static is interesting, and I am satisfied with it.
Discussion