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 ย
Page Routes
Here are all the Page Routes:
-
/homeโ Landing page (shown when not logged in) -
/loginโ Admin login page -
/โ Chat interface (requires authentication) -
/formsโ List all forms -
/forms/[id]โ Render a specific form -
/forms/[id]/submissionsโ List submissions for a specific form
Here are all the API Routes:
-
POST /api/loginโ Authenticate and set session cookie -
POST /api/chatโ AI chat endpoint -
GET ย /api/forms/listโ Get all forms -
POST /api/forms/createโ Create a new form -
GET ย /api/forms/getโ Get form schema by ID -
DELETE /api/forms/deleteโ Delete a form by ID -
POST /api/forms/submitโ Submit a form response -
GET ย /api/forms/[id]โ List submissions for a form -
DELETE /api/forms/[id]/submissionsโ Delete a submission by ID
3. Architecture Overview.
Here's the high-level architecture of the project.
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).
The frontend sends the user message(s) via SSE (
fetch('/api/chat')) to the chat API.-
/api/chatconstructs 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.
- Prepends a system prompt that tells the model to emit JSON UI specs inside
As chunks arrive,
@crayonai/streampipes them into the live chat component and accumulates the output.-
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.
- Extracts the
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! ๐ฅฐ | |
|---|













Top comments (5)
The login and auth is managed by the SDK or you setup something for it?
Was this vibe coded? Because the Project Structure looks like Claude generated! If yes then good job ๐ because even AI generated codes need to be properly guided.
Thanks for open sourcing this, very inspiring project! ๐
Really good project and detailed structure sir
GG @anmolbaranwal !