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 (19)
The login and auth is managed by the SDK or you setup something for it?
The current auth is just a very simple cookie based (not using any SDK). Someone already opened an issue about a possible vulnerability so I'm just fixing it now.
Glad to see the community support so π₯
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.
Actually no -- I donβt use Claude for coding. Nextjs projects just tend to have a similar structure (api routes, components, pages, ...), so it can look ai-generated even when itβs not. I only used Perplexity to ideate before starting and for occasional debugging, nothing more.
Yes! Next.js projects structure looks very similar to what AI models generate. And honestly, using Perplexity for ideas or debugging is still smart. The quality always depends on the person guiding the tool, not the tool itself.
Thanks for open sourcing this, very inspiring project! π
Appreciate it Welly! hope it helps you build something cool.
Good explanation and project, congratulations ππΌ
Congrats on the launchπ! If you ever need help improving your onboarding copy or explaining features more clearly, FAQ or product explainer in place? It helps reduce support questions early and helps end users, if you need one Iβd be glad to assist.
I mean since it's self-hosted, thereβs no need for that. Itβs all free and I didnβt build this for any monetary gain -- just something others can use or build their projects on top of.
Wow. Amazing!
Thanks Dumebi, appreciate you checking this!
Love your style on this approach. Clever my dude!
Thanks John. I have improved the auth (JWT) so this should be good enough now. Others can also use this codebase to build cool stuff on top of it.
Really good project and detailed structure sir
Thanks yash. I will be building at least one solid project every month from now on.
GG @anmolbaranwal !
thanks Athreya. hopefully it helps someone out there building similar ideas.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.