Over the past few weeks, I have been building a Google Forms alternative but with a huge twist.
Rather than creating forms manually, you can chat to develop forms and those forms go live instantly for submissions.
Under the hood, itβs powered by a streaming LLM connected to Next.js & C1 by Thesys. The form spec and submissions are stored in MongoDB.
It would be hard to cover the complete codebase but here are all the important details and everything I learned building this.
What is covered?
In summary, we are going to cover these topics in detail.
- The vision behind the project.
- Tech Stack Used.
- Architecture Overview.
- Data Flow: From Prompt β Form β Save
- How It Works (Under the Hood).
You can check the β¦
Over the past few weeks, I have been building a Google Forms alternative but with a huge twist.
Rather than creating forms manually, you can chat to develop forms and those forms go live instantly for submissions.
Under the hood, itβs powered by a streaming LLM connected to Next.js & C1 by Thesys. The form spec and submissions are stored in MongoDB.
It would be hard to cover the complete codebase but here are all the important details and everything I learned building this.
What is covered?
In summary, we are going to cover these topics in detail.
- The vision behind the project.
- Tech Stack Used.
- Architecture Overview.
- Data Flow: From Prompt β Form β Save
- How It Works (Under the Hood).
You can check the GitHub Repository.
1. The vision behind the project.
I was using Google Forms a few months back and realized it still requires you to build forms manually, which works fine but feels outdated.
So I wondered: what if creating a form were as easy as describing it?
Something like: βI want a registration form with name, email, password and a feedback textboxβ. The AI would take that, generate the UI spec automatically and render it instantly in the browser.
Each form gets its own unique URL for collecting submissions and with a simple cookie-based authentication so only you can create & view the list of forms with their submissions.
Around this time, I came across Thesys and its C1 API, which made it easy to turn natural language descriptions into structured UI components. That sparked the idea to build this project on top of it.
Unlike hosted form tools, this one is completely self-hosted, your data and submissions stay in your own database.
Here is the complete demo showing the flow!
This wasnβt about solving a big problem. It was more of an experiment in understanding how chat-based apps and generative UI systems work under the hood.
To use the application:
- Fork the repository.
- Set your admin password and other credentials in
.env(check the format below). - Deploy it on any hosting provider (Vercel, Netlify, Render) or your own server.
- Visit
/loginand enter your admin password. - After successful login, you will be redirected to the chat interface at
/. - You can now create forms as needed (see the demo above).
You can copy the .env.example file in the repo and update environment variables.
THESYS_API_KEY=<your-thesys-api-key>
MONGODB_URI=<your-mongodb-uri>
THESYS_MODEL=c1/anthropic/claude-sonnet-4/v-20250930
ADMIN_PASSWORD=<your-admin-password>
If you want to use any other model, you can find the list of stable models recommended for production and how their pricing is calculated in the documentation.
Setting up Thesys
The easiest way to get started is using CLI that sets up an API key and bootstraps a NextJS template to use C1.
npx create-c1-app
But letβs briefly understand how Thesys works (which is the core foundation):
β
First, update the OpenAI client configuration to point to Thesys by setting the baseURL to api.thesys.dev and supplying your THESYS_API_KEY. You get an OpenAIβstyle interface backed by Thesys under the hood.
// Prepare Thesys API call (OpenAIβcompatible)
const client = new OpenAI({
baseURL: 'https://api.thesys.dev/v1/embed',
apiKey: process.env.THESYS_API_KEY,
})
β
Calling a streaming chat completion. Setting stream: true lets us progressively emit tokens back to the browser for realβtime UI rendering.
const llmStream = await client.chat.completions.create({
model: process.env.THESYS_MODEL || 'c1/anthropic/claude-sonnet-4/v-20250930',
messages: messagesToSend,
stream: true,
})
β By wrapping the raw LLM stream in our transformStream helper, we extract just the tokenized content deltas and pipe them as an HTTP-streamable response.
const responseStream = transformStream(
llmStream,
(chunk) => chunk?.choices?.[0]?.delta?.content ?? '',
)
return new NextResponse(responseStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
You can try it live on the playground.
2. Tech Stack Used
The tech stack is simple enough with:
- Next.js (App Router) : routing, server rendering and API endpoints
- Thesys GenUI SDK (@thesysai/genui-sdk) : powers the chat interface (C1Chat) and renders generated forms (C1Component)
- C1 by Thesys (Anthropic model) : augments LLMs to respond with interactive UI schemas based on prompts
- @crayonai/stream : streams LLM output to the front end in real time
- MongoDB & Mongoose : stores form definitions and user submissions in DB
- Node.js (Next.js API routes + middleware) : handles backend logic for chat, CRUD, and authentication
If you want to read more about how Thesys Works using C1 API and GenUI React SDK, check out this blog.
Project Structure
Most of the work lives under the src directory, with the Next.js App Router and API routes:
.
βββ .env.example
βββ .gitignore
βββ LICENSE
βββ next.config.ts
βββ package.json
βββ postcss.config.mjs
βββ tsconfig.json
βββ middleware.ts
βββ public/
βββ src/
βββ app/ # Next.js App Router
β βββ api/ # Serverless API routes
β β βββ chat/route.ts # Chat endpoint
β β βββ forms/ # Form CRUD + submissions
β β βββ [id]/ # Form-specific endpoints
β β β βββ submissions/
β β β β βββ [submissionId]/
β β β β β βββ route.ts # Delete submission of a form
β β β β βββ route.ts # GET form submissions
β β βββ create/route.ts # Create new form
β β βββ delete/route.ts # Delete form by ID
β β βββ get/route.ts # Get form by ID
β β βββ list/route.ts # List all forms
β β βββ submit/route.ts # Handle form submission
β β
β βββ assets/ # Local fonts
β βββ forms/
β β βββ [id]/ # Dynamic form route
β β β βββ submissions/
β β β β βββ page.tsx # Show all submissions for a form
β β β βββ page.tsx # Show a single form (renders via C1Component)
β β βββ page.tsx # All forms listing page
β β
β βββ home/ # Landing page (when not logged in)
β β βββ page.tsx
β βββ favicon.ico
β βββ globals.css
β βββ layout.tsx
β βββ page.tsx
β
βββ components/
β βββC1ChatWrapper.tsx
| βββClientApp.tsx
| βββFormsListPage.ts
β βββSubmissionsPage.tsx
β
βββ lib/
βββ dbConnect.ts # MongoDB connection helper
βββ fonts.ts # Next.js font setup
βββ models/ # Mongoose models
β βββ Form.ts
β βββ Submission.ts
βββ utils.ts
4. Data Flow: From Prompt β Form β Save
Here is the complete sequence diagram from Form Creation β Save.
5. How It Works (Under the Hood)
Below is an endβtoβend walkthrough of the main userβfacing flows, tying together chat, form generation, rendering and everything in between.
System Prompt
Here is the system prompt:
const systemPrompt = `
You are a form-builder assistant.
Rules:
- If the user asks to create a form, respond with a UI JSON spec wrapped in <content>...</content>.
- Use components like "Form", "Field", "Input", "Select" etc.
- If the user says "save this form" or equivalent:
- DO NOT generate any new form or UI elements.
- Instead, acknowledge the save implicitly.
- When asking the user for form title and description, generate a form with name="save-form" and two fields:
- Input with name="formTitle"
- TextArea with name="formDescription"
- Do not change these property names.
- Wait until the user provides both title and description.
- Only after receiving title and description, confirm saving and drive the saving logic on the backend.
- Avoid plain text outside <content> for form outputs.
- For non-form queries reply normally.
<ui_rules>
- Wrap UI JSON in <content> tags so GenUI can render it.
</ui_rules>
`
As you can see, it behaves like a Form Builder Assistant.
You can read the official docs for a step-by-step guide on how to add a system prompt to your application.
ChatβDriven Form Design
User types a prompt in the chat widget (C1Chat). 1.
The frontend sends the user message(s) via SSE (fetch('/api/chat')) to the chat API.
1.
/api/chat constructs an LLM request:
- Prepends a system prompt that tells the model to emit JSON UI specs inside
<content>β¦</content>. - Streams responses back to the client.
As chunks arrive, @crayonai/stream pipes them into the live chat component and accumulates the output.
1.
On the stream end, the API:
- Extracts the
<content>β¦</content>payload. - Parses it as JSON.
- Caches the latest schema (in a global var) for potential βsaveβ actions.
- If the user issues a save intent, it POSTs the cached schema plus title/description to
/api/forms/create.
export async function POST(req: NextRequest) {
try {
const incoming = await req.json()
// Normalize client structure...
const messagesToSend = [
{ role: 'system', content: systemPrompt },
...incomingMessages,
]
const client = new OpenAI({
baseURL: 'https://api.thesys.dev/v1/embed',
apiKey: process.env.THESYS_API_KEY,
})
const llmStream = await client.chat.completions.create({
model:
process.env.THESYS_MODEL ||
'c1/anthropic/claude-sonnet-4/v-20250930',
messages: messagesToSend,
stream: true,
})
const responseStream = transformStream(
llmStream,
(chunk) => chunk?.choices?.[0]?.delta?.content ?? '',
{
onEnd: async ({ accumulated }) => {
const rawSpec = Array.isArray(accumulated)
? accumulated.join('')
: accumulated
const match = rawSpec.match(/<content>([\s\S]+)<\/content>/)
if (match) {
const schema = JSON.parse(decodeHtmlEntities(match[1].trim()))
globalForFormCache.lastFormSpec = schema
}
if (isSaveIntent(incomingMessages)) {
const { title, description } = extractTitleDesc(incomingMessages)
await fetch(`${req.nextUrl.origin}/api/forms/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
schema: globalForFormCache.lastFormSpec,
}),
})
}
},
}
) as ReadableStream<string>
return new NextResponse(responseStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
} catch (err: any) {
/* β¦error handlingβ¦ */
}
}
Here is the chat page.
Storing a New Form
Hereβs how it stores form:
POST /api/forms/create(serverless route) receives{ title, description, schema }.- It calls
dbConnect()to get a Mongo connection (with connectionβcaching logic). - It writes a new
Formdocument (Mongoose model) with your UIβschema JSON.
export async function POST(req: NextRequest) {
await dbConnect()
const { title, description, schema } = await req.json()
const form = await Form.create({ title, description, schema })
return NextResponse.json({ id: form._id, success: true })
}
Letβs also see how all the forms are listed using this API route src\app\api\forms\list\route.ts.
export const runtime = 'nodejs'
import { NextResponse } from 'next/server'
import { dbConnect } from '@/lib/dbConnect'
import Form from '@/lib/models/Form'
export async function GET() {
await dbConnect()
const forms = await Form.find({}, '_id title description createdAt')
.sort({ createdAt: -1 })
.lean()
const formattedForms = forms.map((f) => ({
id: String(f._id),
title: f.title,
description: f.description,
createdAt: f.createdAt,
}))
return NextResponse.json({ forms: formattedForms })
}
Here is the listing page.
Rendering the Generated Form
- Visitors navigate to
/forms/[id]. - The pageβs
useEffect()fetches the stored schema fromGET /api/forms/get?id=[id]. - It wraps the raw JSON in
<content>β¦</content>and passes it to C1Component, which renders the fields, inputs, selects and more.
Each form is rendered at src/app/forms/[id]/page.tsx.
useEffect(() => {
async function fetchForm() {
const res = await fetch(`/api/forms/get?id=${id}`)
const data = await res.json()
const wrappedSpec = `<content>${JSON.stringify(data.schema)}</content>`
setC1Response(wrappedSpec)
}
if (id) fetchForm()
}, [id])
if (!c1Response) return <div>Loading...</div>
return (
<C1Component
key={resetKey}
c1Response={c1Response}
isStreaming={false}
onAction={/* β¦see next sectionβ¦ */}
/>
)
Here is an example of a generated form.
Handling Form Submission
- When the user fills and submits the form,
C1Componentfires anonActioncallback. - The callback POSTs
{ formId, response }to/api/forms/submit. - The server writes a new
Submissiondocument linking back to theForm.
Here is the Submission Mongoose Model.
const SubmissionSchema = new mongoose.Schema({
formId: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' },
response: Object, // The filled (submitted) data JSON
createdAt: { type: Date, default: Date.now },
})
export default mongoose.models.Submission ||
mongoose.model('Submission', SubmissionSchema)
Letβs also see how all submissions are shown using API route src\app\api\forms\[id]\submissions\route.ts.
import { NextRequest, NextResponse } from 'next/server'
import { dbConnect } from '@/lib/dbConnect'
import Submission from '@/lib/models/Submission'
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
await dbConnect()
const { id } = await context.params
try {
const submissions = await Submission.find({ formId: id }).sort({
createdAt: -1,
})
return NextResponse.json({ success: true, submissions })
} catch (err) {
console.error('Error fetching submissions:', err)
return NextResponse.json(
{ success: false, error: 'Failed to fetch submissions' },
{ status: 500 }
)
}
}
Hereβs the submissions view. You can also delete entries or export all responses in Markdown.
Admin Listing & Deletion
Under the authenticated area, you can list all forms (GET /api/forms/list), view/βdelete individual forms and inspect submissions: each via dedicated API routes.
Here is a small snippet to list all forms (src/app/api/forms/list/route.ts).
export async function GET() {
await dbConnect()
const forms = await Form.find({}, '_id title description createdAt')
.sort({ createdAt: -1 })
.lean()
const formattedForms = forms.map(f => ({
id: String(f._id),
title: f.title,
description: f.description,
createdAt: f.createdAt,
}))
return NextResponse.json({ forms: formattedForms })
}
Here is a small snippet to delete the form (src/app/api/forms/delete/route.ts).
export async function DELETE(req: NextRequest) {
await dbConnect()
try {
const { id } = await req.json()
if (!id) {
return NextResponse.json(
{ success: false, error: 'Form ID is required' },
{ status: 400 }
)
}
await Form.findByIdAndDelete(id)
return NextResponse.json({ success: true })
} catch (err) {
console.error('Error deleting form:', err)
return NextResponse.json(
{ success: false, error: 'Failed to delete form' },
{ status: 500 }
)
}
}
Authentication Flow
Itβs a simple admin auth for creating and deleting forms. This is how itβs implemented:
β
Login Endpoint - POST /api/login checks the provided { password } against process.env.ADMIN_PASSWORD. On success, it sets a secure, HTTP-only cookie named auth.
β
Middleware Protection - A middleware file (middleware.ts) inspects the auth cookie. If the user isnβt authenticated, theyβre redirected from / to the public /home page.
β
Environment Variable - Add ADMIN_PASSWORD in your .env (also included in .env.example) so the login route can verify credentials.
So the listing forms page, chat page and the submissions page are protected using this method.
Thereβs a lot to improve: better schema validation, versioning, maybe even multi-user sessions.
But it already does what I hoped for: you talk, it builds and suddenly you have something that works. Let me know what you think of the app in the comments.
Have a great day! Until next time :)
You can check my work at anmolbaranwal.com. Thank you for reading! π₯°