In the browser
Next.js
The App Router with the page rendered on the server and the answer drawn in a client component.
npm install @morphscreen/webpnpm add @morphscreen/webyarn add @morphscreen/webbun add @morphscreen/webThe core runs in the browser, so the answer goes in a client component. The page around it stays a server component.
Serve the core
Copy morphscreen.wasm into public/ before each build, so it's served at /morphscreen.wasm:
// Copies the core out of the package into public/, where the app serves it
// and loadCore("/morphscreen.wasm") fetches it. Run before each build.
import { copyFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
mkdirSync("public", { recursive: true });
copyFileSync(require.resolve("@morphscreen/web/morphscreen.wasm"), "public/morphscreen.wasm");{ "scripts": { "build": "node copy-wasm.mjs && next build" } }The stylesheet
import "@morphscreen/web/morphscreen.css";
export const metadata = { title: "MorphScreen with Next.js" };
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}The answer, in a client component
"use client";
import { useEffect, useState } from "react";
import { loadCore, type Core } from "@morphscreen/web";
import { Answer } from "@morphscreen/web/react";
import { SAMPLE } from "./sample";
export function LiveAnswer() {
const [core, setCore] = useState<Core | null>(null);
const [text, setText] = useState("");
useEffect(() => {
loadCore("/morphscreen.wasm").then(setCore);
}, []);
// Your server's stream goes here; this grows a saved answer piece by piece.
useEffect(() => {
if (!core) return;
let n = 0;
const t = setInterval(() => {
n += 24;
setText(SAMPLE.slice(0, n));
if (n >= SAMPLE.length) clearInterval(t);
}, 10);
return () => clearInterval(t);
}, [core]);
const done = text.length >= SAMPLE.length;
useEffect(() => {
if (done) document.body.dataset.done = "yes";
}, [done]);
return core ? <Answer core={core} text={text} done={done} /> : null;
}import { LiveAnswer } from "./answer";
// A server component: the page renders on the server, the answer in the browser.
export default function Page() {
return (
<main>
<LiveAnswer />
</main>
);
}Your server route
Your /api/agent route can be a Route Handler doing what the Quick start's server does: add X-MorphScreen-Key, set the model, and return MorphScreen's response body as the response.
// The page's AG-UI runs go on to MorphScreen with your key, and the events
// stream straight back. The key never reaches the browser, and the server
// picks the model.
export async function POST(req: Request) {
const run = await req.json();
run.forwardedProps = { ...run.forwardedProps, model: "deepseek/deepseek-v4.1-flash" };
const upstream = await fetch("https://api.morphscreen.com/v1/ui", {
method: "POST",
headers: { "Content-Type": "application/json", "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY ?? "" },
body: JSON.stringify(run),
});
return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream" } });
}