SSR & Next.js
What Cloudlet renders on the server, who owns the controller, and how to wire it into the Next.js App Router, the Pages Router and Vite.
On this page
Cloudlet renders nothing on the server and appears after hydration, so it never causes a hydration mismatch. This page explains what that means for where the controller lives, then gives complete setups for the Next.js App Router and Pages Router, a note on Vite, and how the request's CSP nonce reaches the Toaster.
What renders on the server
The Toaster's server output is empty, and so is its first render during hydration, even when notifications were accepted before it mounted. After mount it portals its host into the document and presents whatever is pending. Notifications you show before the Toaster mounts are not lost: they wait in the controller.
The package keeps the "use client" directive on its React entry, so importing Toaster, CloudletProvider or useCloudlet from a Server Component file marks the boundary correctly. The controller itself is a live object with methods and subscriptions; it is not serializable and never crosses from a Server Component into a client one.
If you read the controller's state in your own components, subscribe the same way the Toaster does. getServerSnapshot() returns an empty snapshot, so the server and the first client render agree, and the real state arrives right after hydration:
'use client';
import { useSyncExternalStore } from 'react';
import { useCloudlet } from '@cloudlet/toast';
/** How many notifications wait in the stack; 0 on the server and during hydration. */
export function NotificationCount() {
const cloudlet = useCloudlet();
const count = useSyncExternalStore(
cloudlet.subscribe,
() => cloudlet.getSnapshot().stack.length,
() => cloudlet.getServerSnapshot().stack.length,
);
return count > 0 ? <span>{count} new</span> : null;
}One controller per session or request
A controller holds one user's notifications. Create it once per browser session, and never share one between users:
- In the browser, one controller lives as long as the page or the part of the application that owns it.
- On the server, a controller that takes part in rendering belongs to that request alone. A module-level controller in code that also runs on the server would be shared by every request, so in server-rendered applications the controller is created in the browser, by a client component, as below.
When you render on the server yourself, create the controller inside the request and dispose it once the response is written:
import { renderToString } from 'react-dom/server';
import { CloudletProvider, createCloudlet, Toaster } from '@cloudlet/toast';
function App() {
return <main>Weather station</main>;
}
export function render() {
// This request's own controller: the Toaster renders nothing into the HTML.
const cloudlet = createCloudlet();
try {
return renderToString(
<CloudletProvider controller={cloudlet}>
<App />
<Toaster />
</CloudletProvider>,
);
} finally {
cloudlet.dispose();
}
}The browser entry then creates its own controller and hydrates the same tree.
Next.js App Router
The App Router renders every page on the server first, so the controller is owned by a client component. Its effect creates the controller, and its cleanup disposes exactly that instance. Your pages keep rendering on the server: until the controller exists, the Provider gets null, the Toaster renders nothing and your children render as usual.
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { CloudletProvider, createCloudlet, Toaster, type ReactCloudletController } from '@cloudlet/toast';
export function NotificationOwner({ children, nonce }: { children: ReactNode; nonce?: string | undefined }) {
const [controller, setController] = useState<ReactCloudletController | null>(null);
useEffect(() => {
const instance = createCloudlet();
setController(instance);
// Dispose this instance, not whichever controller is current.
return () => instance.dispose();
}, []);
// `null` on the server and in the first client render: the Toaster waits, the children do not.
return (
<CloudletProvider controller={controller}>
{children}
<Toaster {...(nonce ? { nonce } : {})} />
</CloudletProvider>
);
}The root layout imports the stylesheet once and wraps the application in the owner:
import '@cloudlet/toast/styles.css';
import type { ReactNode } from 'react';
import { NotificationOwner } from '../components/notification-owner';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<NotificationOwner>{children}</NotificationOwner>
</body>
</html>
);
}Client components show notifications through useOptionalCloudlet(). Before mount it returns null, which only matters for code that runs during render; events fire after hydration, when the controller exists:
'use client';
import { useOptionalCloudlet } from '@cloudlet/toast';
export function SaveButton() {
const cloudlet = useOptionalCloudlet();
return (
<button
disabled={!cloudlet}
onClick={() => cloudlet?.show({ title: 'Forecast saved', kind: 'success' })}
>
Save forecast
</button>
);
}useCloudlet() throws while the controller is null, so keep it for components that render only after the owner has mounted, such as content of a dialog the user opens.
The shadcn registry item works a little differently: its wrapper renders the Provider and the Toaster for a controller you pass in, and the notification-owner.tsx of Installation renders it once that controller exists.
React Strict Mode
In development Strict Mode runs the owner's effect, its cleanup, and the effect again. The first controller is disposed before anything uses it, the second one is stored, and the Toaster attaches to the second. That works because the cleanup disposes the instance its own effect created. Two variants break under the replay:
- disposing the controller from state (
controller.dispose()in a cleanup), which disposes the instance the second setup still needs; - creating the controller once outside the effect and disposing it in the cleanup, which leaves the remounted owner with a disposed controller.
Nonce in the App Router
Under a strict Content Security Policy every Toaster needs the request's nonce (see Strict CSP). In Next.js, a proxy.ts generates a nonce for each request and puts it into the Content-Security-Policy header and an x-nonce request header, as described in the Next.js CSP guide. Next.js applies it to its own scripts. Read it in the root layout and pass it to the owner, which hands it to the Toaster:
import '@cloudlet/toast/styles.css';
import { headers } from 'next/headers';
import type { ReactNode } from 'react';
import { NotificationOwner } from '../components/notification-owner';
export default async function RootLayout({ children }: { children: ReactNode }) {
const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
<html lang="en">
<body>
<NotificationOwner nonce={nonce}>{children}</NotificationOwner>
</body>
</html>
);
}A nonce exists only for a request, so pages that use it are rendered dynamically; a statically generated page has no nonce to pass.
Next.js Pages Router
In the Pages Router, global CSS is imported from the custom App, and the same client owner wraps every page. _app stays mounted across client-side navigations, so one controller serves the whole session:
import '@cloudlet/toast/styles.css';
import type { AppProps } from 'next/app';
import { NotificationOwner } from '../components/notification-owner';
export default function App({ Component, pageProps }: AppProps) {
return (
<NotificationOwner>
<Component {...pageProps} />
</NotificationOwner>
);
}If you use the shadcn wrapper, move its two CSS imports into _app in the same order: @cloudlet/toast/styles.css, then toaster.css.
Both routers need a matching pair of react and react-dom, in the range Cloudlet supports.
Vite and other client-only apps
A single-page application without server rendering has nothing to hydrate. Create the controller once at module scope of the client entry and render the Provider and the Toaster there, as in the Quick start. With Vite's SSR mode, the server entry follows One controller per session or request.
Next steps
- Try it in the Playground: the Toaster of this site is owned exactly like the App Router example above.
- Styling & strict CSP: the policy the nonce belongs to, and where the stylesheet goes.
- Toaster props:
container,nonceand every other prop.