DEV Community

Cover image for I built a self-hosted Google Forms alternative and made it open source! 🎉
Anmol Baranwal
Anmol Baranwal Subscriber

Posted on

I built a self-hosted Google Forms alternative and made it open source! 🎉

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.

ai form builder


What is covered?

In summary, we are going to cover these topics in detail.

  1. The vision behind the project.
  2. Tech Stack Used.
  3. Architecture Overview.
  4. Data Flow: From Prompt → Form → Save
  5. 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.

apps with and without c1 api

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:

  1. Fork the repository.
  2. Set your admin password and other credentials in .env (check the format below).
  3. Deploy it on any hosting provider (Vercel, Netlify, Render) or your own server.
  4. Visit /login and enter your admin password.
  5. After successful login, you will be redirected to the chat interface at /.
  6. 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

thesys cli

thesys cli

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,
    })
Enter fullscreen mode Exit fullscreen mode

✅ 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,
    })
Enter fullscreen mode Exit fullscreen mode

✅ 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',
      },
    })
Enter fullscreen mode Exit fullscreen mode

You can try it live on the playground.

thesys 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.

c1 thesys

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     
Enter fullscreen mode Exit fullscreen mode

 

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>
`
Enter fullscreen mode Exit fullscreen mode

As you can see, it behaves like a Form Builder Assistant.

system prompt

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

  1. User types a prompt in the chat widget (C1Chat).

  2. The frontend sends the user message(s) via SSE (fetch('/api/chat')) to the chat API.

  3. /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.
  4. As chunks arrive, @crayonai/stream pipes them into the live chat component and accumulates the output.

  5. 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… */
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is the chat page.

chat endpoint

 

Storing a New Form

Here's how it stores form:

  1. POST /api/forms/create (serverless route) receives { title, description, schema }.
  2. It calls dbConnect() to get a Mongo connection (with connection‑caching logic).
  3. It writes a new Form document (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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

Here is the listing page.

forms list

 

Rendering the Generated Form

  1. Visitors navigate to /forms/[id].
  2. The page’s useEffect() fetches the stored schema from GET /api/forms/get?id=[id].
  3. 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… */}
  />
)
Enter fullscreen mode Exit fullscreen mode

Here is an example of a generated form.

form page

 

Handling Form Submission

  1. When the user fills and submits the form, C1Component fires an onAction callback.
  2. The callback POSTs { formId, response } to /api/forms/submit.
  3. The server writes a new Submission document linking back to the Form.

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)
Enter fullscreen mode Exit fullscreen mode

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 }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s the submissions view. You can also delete entries or export all responses in Markdown.

submissions page

 

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

 

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.

login page


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! 🥰
twitter github linkedin

Ending GIF waving goodbye

Top comments (1)

Collapse
 
lovestaco profile image
Athreya aka Maneshwar