Develop a front end using NextJS
This project is about developing a basic chatbot ui that will interact with a Forma agent, communicating via Streaming/SSE using the Vercel AiSDK v5.
We will use the following tech stack:
- NextJS as the framework
- Tailwindcss for styling
- shadcn for making the components functional and pretty effortlesly
- Vercel AiSDK v5 for processing the streamed responses
Setting up your Forma AI Agent so respect this communication protocol
In order for our Forma agent to be compatible with this chatbot, we need to set the client.flavor to ai-sdk-v5
persist_sessions: false # <-- MESSAGES STORED IN THE FRONT END
client:
flavor: ai-sdk-v5 # <-- FOR AI_SDK COMPATIBILITY
start:
nodes:
- llm:
provider: ollama
system_prompt: 'you are a helpful assistant'
Also, remember to setup an API Key. Add this to the .env file in your Forma directory:
# .env, within the Forma agent directory
FORMA_AGENT_KEY=fake-key-which-should-be-longer-in-production
Setting up our tech stach
THe first thing to do is to start a NextJS project. You do this by running the following command in your terminal
APP_NAME=nextjs-aisdk-5
npx create-next-app@latest --app --ts --tailwind --app --turbopack --yes $APP_NAME
cd $APP_NAME
That command created a new NextJS app, with TailwindCSS already in place. Now you can run
npm run dev
And go to http://localhost:3000 on your browser. You should see something like this:

All right, so this does not like a chatbot... but we will get there.
Turn this into a chat
Add support for the communication protocols
A chat needs messages, among other things. Let's install some libraries that will help us send and store messages
npm install ai '@ai-sdk/react'
Cleanup the template
Remove all the stuff in the public folder within your project. You can do it using your mouse, or—if you use Mac or Linux—you can do this:
# If it asks you if you are sure... yeah, this should be safe.
rm ./public/*
Add some useful UI components
This is a very simple template, and we might not need a whole library. However, as things scale, a library like shadcn becomes very valuable. I guess their aesthetic appeal is always debatable. Regardless, my view is that they have built-in functionalities that are quite pleasing for users (e.g., close a dialog by pressing the 'Esc' key) and their style can be fully modified.
# It will ask you whether you want to install some dependencies, and
# if you want to create a `components.json` file. Say Yes to both.
npx shadcn@latest add button
Replace the landing page
There is a file in your project called page.tsx (located at your-project-name/app/page.tsx). Replace everything in it by copying and pasting the code below.
This code contains no Forma-specific code, and it is almost entirely just React/NextJS. We will comment some specific bits of this code in the next section.
// ./app/page.tsx
"use client"
import { Button } from "@/components/ui/button";
import { ArrowUpIcon, Sparkles } from "lucide-react"
import { useChat } from "@ai-sdk/react";
import { type UIMessage } from "@ai-sdk/react";
import { UIDataTypes, UITools, type UIMessagePart } from 'ai'
import { useCallback, useRef, useState } from "react";
type MessagePart = UIMessagePart<UIDataTypes, UITools>
type ChatRequestOptions = {
headers?: Record<string, string> | Headers
body?: object,
data?: object
}
function Part({ part }: { part: MessagePart }) {
switch (part.type) {
case "text":
return <p>{part.text}</p>
default:
console.log(`unsupported message part of type '${part.type}': ${JSON.stringify(part)}`)
return null
}
}
function SubmitForm({ sendMessage }: {
sendMessage: (message: { text: string }, options?: ChatRequestOptions) => Promise<void>
}) {
const [input, setInput] = useState<string>("")
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const submit = useCallback(async (text: string) => {
let area = textAreaRef.current
if (area) {
area.value = ""
}
sendMessage({ text })
}, [])
const onKey = useCallback((e: any) => {
let newv = e.target.value.trim()
setInput(newv)
if (e.key === 'Enter') {
submit(newv)
}
}, [setInput])
return <div className='h-fit'>
<div className='flex items-center p-2 rounded-2xl border m-1'>
<textarea ref={textAreaRef} onKeyUp={onKey} className='flex-grow outline-none focus:outline-none resize-none' placeholder="Ask me anything!" />
<Button size="icon" aria-label="Submit" onClick={() => {
submit(input)
}} >
<ArrowUpIcon />
</Button>
</div>
</div>
}
function Chatlog({ messages }: {
messages: UIMessage[]
}) {
return <div className="h-full flex-grow flex flex-col p-2 overflow-y-scroll">
{messages.map((m) => {
const parts = m.parts.map((p, i) => {
return <Part key={i} part={p} />
})
switch (m.role) {
case "assistant":
return <div key={m.id} className='w-full flex'>
<span><Sparkles /></span>
<div className='bg-transparent text-foreground py-1 px-3 max-w-[70%] w-fit'>{parts}</div>
<span className='flex-grow'></span>
</div>
case "user":
return <div key={m.id} className='w-full flex'>
<span className='flex-grow'></span>
<div className='bg-primary text-primary-foreground rounded-md py-1 px-3 max-w-[70%] w-fit'>{parts}</div>
</div>
case "system":
return null
default:
return <code>unsupported role {m.role}: {JSON.stringify(m)}</code>
}
})}
</div>
}
export default function Home() {
const {
messages,
sendMessage,
} = useChat<UIMessage>({
onError: (e) => {
console.warn(e)
}
});
return (
<main className='flex flex-col h-screen w-full max-w-3xl mx-auto overflow-hidden'>
<Chatlog messages={messages} />
<SubmitForm sendMessage={sendMessage} />
</main>
);
}
Let's discuss this code a little bit
You might noticed that 93% if this code is just Typescript and NextJS/React. In fact there is 0% Forma-specific code, and just about 7% (8 lines) of AiSdk v5 code.
The code worth paying attention to is the following:
// This little piece of magic gives us two things
const {
messages, // <-- (1) A list of messages to render
sendMessage, // <-- (2) A function to send new messages
} = useChat<UIMessage>({
onError: (e) => {
console.warn(e) // <-- A bit of error handling here
}
});
So, with those pieces in place, I can now just render the messages in any way I like, and I can call the sendMessage function to send messages.
Some things to know:
- By default,
sendMessagesends the messages to theapi/chatendpoint. We will use this default. - The list of
messages is updated automatically as events are streamed from the back end.
Setup the Back End
As mentioned earlier, sendMessage sends messages to api/chat, so we need to develop that. The path of every message will be the following:
- You send a message from your browser (i.e., the Front end)
- The message is received by the
api/chatendpoint (i.e., the Back End). It will add Authentication, and forward it to the Forma agent - The Forma agent processes the request, responding to the Back End
- The Back-End pipes the messages to the Front-End.
We use this method because Forma agents are not meant to handle security (beyond an API Key). By calling forma agents from your back-end, you can:
- Validate the users identity and permissions
- Keep the Forma Agent API Key secure
- Make the call to Forma agents a part of a longer process (e.g., updating a file in a bucket)
YOU ARE RESPONSIBLE FOR SECURING YOUR AGENT AND ADDING AUTHENTICATION
So, add the code below to app/api/chat/route.ts. This path is the one the useChat function defines by default.
// app/api/chat/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json() // Parse the front-end request
try {
// Send request to Forma Agent
const r = await fetch(`${process.env.FORMA_AGENT_URL!}/v1/chat`, {
method: 'POST',
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.FORMA_AGENT_KEY!}`
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (!r.ok) {
// check if this was successful
let error = await r.text()
console.error(error)
return new NextResponse(JSON.stringify({
status: r.status,
error
}), { status: r.status });
}
// Pipe Forma's stream directly to client,
// adding the headers expected by the front-end
const sseHeaders = {
"x-vercel-ai-ui-message-stream": "v1",
"Transfer-Encoding": "chunked",
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Content-Type': 'text/event-stream',
"X-Accel-Buffering": "no"
};
return new NextResponse(r.body, {
status: 200,
headers: sseHeaders,
});
} catch (error) {
console.error('Error proxying SSE stream:', error);
return new NextResponse("Failed to connect to the streaming service.", { status: 500 });
}
}
You also need to add the Environment variables we are referencing. Put them in your .env file:
# .env file of your Client
# WHERE the agent is
FORMA_AGENT_URL=http://localhost:8080
# API Key. Should match the one you set on your Forma .env file
FORMA_AGENT_KEY=fake-key-which-should-be-longer-in-production
Run it
Now, open two terminal windows, and run these two:
# Go to wherever your forma agent is
cd <forma/path>
# The Forma CLI
forma serve
# Run the Web App you just made
npm run dev