In the browser

Next.js

The App Router with the page rendered on the server and the answer drawn in a client component.

View as Markdown
Terminal
npm install @morphscreen/web
Terminal
pnpm add @morphscreen/web
Terminal
yarn add @morphscreen/web
Terminal
bun add @morphscreen/web

The 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:

copy-wasm.mjs
// 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");
package.json
{ "scripts": { "build": "node copy-wasm.mjs && next build" } }

The stylesheet

app/layout.tsx
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

app/answer.tsx
"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;
}
app/page.tsx
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.

app/api/agent/route.ts
// 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" } });
}