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 (1)
GG @anmolbaranwal !