Yes you read it right, we can stream components from server actions and with NextJs is pretty straight forward.
This is a Next.js project bootstrapped with create-next-app
.
First, run the development server:
npm run dev # or yarn dev # or pnpm dev # or bun dev
Open http://localhost:3000 with your browser to see the result.
In this new era of React and NextJS there are two type of components:
client
. They are interactive and can use also the fancy hooks available. They render both in Server(render here) and Client(hydrate here). In NextJS every component by default is Server, so we have to explicitly add use client
directive at the top of the component file to make it a client component.{\"children\":[[\"$\",\"h1\",null,{\"children\":\"Server Action streaming components example\"}]}
If we try to consume a Server function
in a client component that stream a client component back as a response of server action as shown below
export async function addPerson(data) { return { ui: ( <div id="streamed component"> <Hello /> // Pure Static component <Counter /> // Use useState </div> ), }; }
⨯ Error: Could not find the module "\stream-components-server-action\src\app\_components\counter.jsx#default" in the React Client Manifest. This is probably a bug in the React Server Components bundler.
export async function addPerson(data) { return { ui: ( <div id="streamed component"> <Hello /> // Pure Static component </div> ), }; }
With the above action our React element that is returned as ui
will be passed as following:
0:["$@1",["development",null]] 2:D{"name":"Hello","env":"Server"} 2:["$","div",null,{"children":["$","h2",null,{"children":["Hellooo ","Test"]}]}] 1:{"ui":["$","div",null,{"id":"streamed component","children":"$2"}]}
This shows that we just pass the fully parsed React Element(object) which is basically an output of passing our component to CreateElement
. We never get thejs
of that component in FE.
This is super cool but wait, what if we need interactivity, there is no easy way around it if you don't want to ship the JS for that component to FE.
But if you just want to deicide what component to render from the Server Function
you can still render a fully interactive component by following these steps:
Instead of directly calling the Server Function
in the Client, pass that function as a prop to the client component via a server component.
In this Server component we define the Server Function that renders the Interactive Client component and pass it to a Client component
import Counter from "./_components/counter"; export default function Home() { async function getComponent() { "use server"; return ( <div id="streamed"> <Counter /> // Client compomemnt with state </div> ); } return ( // passing server action as a prop <Person getComponent={getComponent} /> ); }
Now we can just render it in the Client component as
export default function Person({ getComponent }) { const [result, setResult] = useState(); const [comp, setComp] = useState(); useEffect(() => { getComponent().then(setComp); }, []); return <>{comp}</>; }
But remember this Counter component is not getting rendered in the Server, the output returned form server will be like this
0:["$@1",["development",null]] 2:I["(app-pages-browser)/./src/app/_components/counter.jsx",["app/page","static/ chunks/app/page.js"],"default"] 1:["$","div",null,{"id":"streamed","children":["$","$L2",null,{}]}]
Noticed the reference to the chunks/app/page.js
, in the network tab you can find the file and notice that the Code for Counter component is there
.
I tried the following ways to add an interactive component via server action but they all resulted in error, mentioned above:
So I came up with a work around that let's me add the interactive component either as a first child
or the last child
of the Server component after I receive the response. Its not similar to adding the siblings of the Server returned component but mutating it's children to add more elements.
const handleSubmit = async (e) => { e.preventDefault(); const data = await addPerson(e.currentTarget.elements.username.value); // now we can append a new child either at top or at last of the strmaed response const originalChildren = data.ui.props.children; console.log({ originalChildren }); // here we append a new children to the original element as a last child const CounterEl1 = <Counter base={true} />; const CounterEl2 = <Counter base={false} />; const ui = cloneElement(data.ui, {}, [ CounterEl1, originalChildren, CounterEl2, ]); setResult({ ui: ui, }); };
![(UI)](./public/stream-comp.png)
- This is a simple example, but I have used this in my [portfolio](https://cdasauni.com) to Stream a Component that I create after the LLM response and then mutate it to add an interactive button to it.
![(UI)](./public/portfolio.png)