# How to Build a Polls and Surveys with Replit

- Tool: How to Build with Replit
- Difficulty: Beginner
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a polls and surveys app in Replit in 30-60 minutes. Use Replit Agent to generate an Express + PostgreSQL app where you create multi-question surveys with different question types, share a public link, and view aggregated results as bar charts and averages. No coding experience needed. Deploy on Autoscale.

## Before you start

- A Replit account (Free plan is sufficient)
- A list of question types you want to support (text, multiple choice, rating, etc.)
- Basic understanding of what a database table is (no coding experience needed)
- Optional: a specific survey you want to build as your first test

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Repl and use the Agent prompt below to generate the full surveys app with Drizzle schema, routes, and React frontend in one shot.

```
// Type this into Replit Agent:
// Build a polls and surveys platform with Express and PostgreSQL using Drizzle ORM.
// Tables:
// - surveys: id serial pk, creator_id text not null, title text not null, description text,
//   status text default 'draft' (enum: draft/active/closed),
//   share_code text unique not null, allow_anonymous boolean default true,
//   created_at timestamp default now()
// - questions: id serial pk, survey_id integer FK surveys not null,
//   type text not null (enum: text/textarea/radio/checkbox/rating/number/dropdown),
//   text text not null, options jsonb (array of strings for radio/checkbox/dropdown),
//   is_required boolean default true, position integer not null
// - responses: id serial pk, survey_id integer FK surveys not null,
//   respondent_id text (null if anonymous), submitted_at timestamp default now()
// - answers: id serial pk, response_id integer FK responses not null,
//   question_id integer FK questions not null, value text not null
// Routes: POST /api/surveys (create — auto-generate share_code),
// GET /api/surveys (list user's surveys with response counts),
// PUT /api/surveys/:id (update title/description/status),
// POST /api/surveys/:id/questions (add question),
// PUT /api/surveys/:id/questions/:qid (update question),
// DELETE /api/surveys/:id/questions/:qid (remove),
// PATCH /api/surveys/:id/questions/reorder (update positions),
// GET /api/s/:shareCode (public — survey with questions),
// POST /api/s/:shareCode/submit (submit response, no auth required),
// GET /api/surveys/:id/responses (list responses),
// GET /api/surveys/:id/results (aggregated results).
// Use Replit Auth for survey creators only.
// React frontend: survey builder, public survey form (one question at a time),
// results dashboard. Bind server to 0.0.0.0.
```

> Pro tip: After Agent creates the schema, open Drizzle Studio (database icon in Replit sidebar) and insert a test survey row manually to verify the share_code column was created correctly before building the public survey page.

**Expected result:** A running Express app with all four tables and a React frontend. Opening the app shows a survey builder dashboard (empty until you create your first survey).

### 2. Add the share code generator and survey creation

Every survey gets a short unique share code that forms the public URL. The create route auto-generates this code. Once created, the survey is in 'draft' status until you publish it.

```
const express = require('express');
const { db } = require('../db');
const { surveys } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

const router = express.Router();

// Generate a short, unique 8-character share code
function generateShareCode() {
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
  return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}

// POST /api/surveys — create new survey
router.post('/', async (req, res) => {
  const creatorId = req.user?.id;
  if (!creatorId) return res.status(401).json({ error: 'Login required to create surveys' });

  const { title, description, allowAnonymous = true } = req.body;

  // Generate unique share code (retry on collision)
  let shareCode;
  let attempts = 0;
  while (attempts < 5) {
    shareCode = generateShareCode();
    const existing = await db.select().from(surveys)
      .where(eq(surveys.shareCode, shareCode));
    if (existing.length === 0) break;
    attempts++;
  }

  const [survey] = await db.insert(surveys).values({
    creatorId,
    title,
    description: description || null,
    shareCode,
    allowAnonymous,
    status: 'draft',
  }).returning();

  res.status(201).json({
    ...survey,
    shareUrl: `${process.env.REPLIT_DEPLOYMENT_URL || ''}/s/${shareCode}`,
  });
});

// GET /api/surveys — list creator's surveys with response counts
router.get('/', async (req, res) => {
  const creatorId = req.user?.id;
  if (!creatorId) return res.status(401).json({ error: 'Login required' });

  const result = await db.execute(sql`
    SELECT s.*, COUNT(r.id) AS response_count
    FROM surveys s
    LEFT JOIN responses r ON r.survey_id = s.id
    WHERE s.creator_id = ${creatorId}
    GROUP BY s.id
    ORDER BY s.created_at DESC
  `);

  res.json(result.rows);
});

module.exports = router;
```

**Expected result:** POST /api/surveys creates a survey and returns the share_url. GET /api/surveys shows all surveys with response_count. The share URL opens the public survey page.

### 3. Build the public survey submit route

The public submit route accepts responses without authentication. It validates required questions, creates a response record, and bulk-inserts all answers in one operation. The share code acts as the public identifier.

```
const { surveys, questions, responses, answers } = require('../../shared/schema');
const { eq, and } = require('drizzle-orm');

// GET /api/s/:shareCode — get survey with questions for respondent
router.get('/s/:shareCode', async (req, res) => {
  const [survey] = await db.select().from(surveys)
    .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));

  if (!survey) return res.status(404).json({ error: 'Survey not found or not active' });

  const questionList = await db.select().from(questions)
    .where(eq(questions.surveyId, survey.id))
    .orderBy(questions.position);

  res.json({ ...survey, questions: questionList });
});

// POST /api/s/:shareCode/submit — submit response (no auth required)
router.post('/s/:shareCode/submit', async (req, res) => {
  const [survey] = await db.select().from(surveys)
    .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));

  if (!survey) return res.status(404).json({ error: 'Survey not found' });

  const { answersMap, respondentId } = req.body;
  // answersMap: { questionId: 'answer value' or ['option1', 'option2'] for checkboxes }

  // Validate required questions
  const questionList = await db.select().from(questions)
    .where(eq(questions.surveyId, survey.id));

  const missingRequired = questionList.filter(q =>
    q.isRequired && !answersMap[q.id] &&
    (Array.isArray(answersMap[q.id]) ? answersMap[q.id].length === 0 : !answersMap[q.id])
  );

  if (missingRequired.length > 0) {
    return res.status(400).json({
      error: 'Required questions not answered',
      missingQuestions: missingRequired.map(q => q.id),
    });
  }

  // Create response record
  const [response] = await db.insert(responses).values({
    surveyId: survey.id,
    respondentId: survey.allowAnonymous ? null : (respondentId || req.user?.id || null),
  }).returning();

  // Bulk insert answers
  const answerRows = questionList
    .filter(q => answersMap[q.id] !== undefined)
    .map(q => ({
      responseId: response.id,
      questionId: q.id,
      value: Array.isArray(answersMap[q.id])
        ? JSON.stringify(answersMap[q.id])
        : String(answersMap[q.id]),
    }));

  if (answerRows.length > 0) {
    await db.insert(answers).values(answerRows);
  }

  res.status(201).json({ responseId: response.id, message: 'Response submitted successfully' });
});
```

**Expected result:** GET /api/s/abc12345 returns the survey with questions (only if status = 'active'). POST /api/s/abc12345/submit with answersMap creates a response record and inserts all answers in one batch.

### 4. Build the results aggregation endpoint

The results endpoint calculates aggregated data for each question type: count-per-option for radio/checkbox, average and distribution for rating/number, and recent sample for text/textarea. This powers the results dashboard charts.

```
// GET /api/surveys/:id/results — aggregated results per question
router.get('/:id/results', async (req, res) => {
  const creatorId = req.user?.id;
  const surveyId = parseInt(req.params.id);

  // Verify ownership
  const [survey] = await db.select().from(surveys)
    .where(and(eq(surveys.id, surveyId), eq(surveys.creatorId, creatorId)));
  if (!survey) return res.status(403).json({ error: 'Not authorized' });

  const questionList = await db.select().from(questions)
    .where(eq(questions.surveyId, surveyId))
    .orderBy(questions.position);

  const totalResponses = await db.execute(sql`
    SELECT COUNT(*) as count FROM responses WHERE survey_id = ${surveyId}
  `);

  const results = await Promise.all(questionList.map(async (q) => {
    const allAnswers = await db.select().from(answers)
      .where(eq(answers.questionId, q.id));

    let aggregated = {};

    if (q.type === 'radio' || q.type === 'dropdown') {
      const counts = {};
      allAnswers.forEach(a => { counts[a.value] = (counts[a.value] || 0) + 1; });
      aggregated = { type: 'counts', data: counts };
    } else if (q.type === 'checkbox') {
      const counts = {};
      allAnswers.forEach(a => {
        const selected = JSON.parse(a.value || '[]');
        selected.forEach(opt => { counts[opt] = (counts[opt] || 0) + 1; });
      });
      aggregated = { type: 'counts', data: counts };
    } else if (q.type === 'rating' || q.type === 'number') {
      const values = allAnswers.map(a => parseFloat(a.value)).filter(v => !isNaN(v));
      const avg = values.length > 0 ? (values.reduce((s, v) => s + v, 0) / values.length).toFixed(2) : null;
      aggregated = { type: 'average', average: avg, count: values.length };
    } else {
      // text / textarea — return recent samples
      aggregated = {
        type: 'text',
        samples: allAnswers.slice(-10).map(a => a.value),
      };
    }

    return { question: q, ...aggregated, totalAnswers: allAnswers.length };
  }));

  res.json({
    survey,
    totalResponses: parseInt(totalResponses.rows[0].count),
    results,
  });
});
```

**Expected result:** GET /api/surveys/1/results returns per-question aggregations: radio questions show option counts as an object, rating questions show average and count, text questions show recent answer samples.

### 5. Deploy on Autoscale

Surveys get traffic spikes when share links are posted in newsletters or social media. Autoscale handles sudden traffic bursts and scales back down when the rush ends. The server must bind to 0.0.0.0.

```
// server/index.js — final server setup
const express = require('express');
const path = require('path');
const { requireAuth } = require('@replit/repl-auth');

const surveysRouter = require('./routes/surveys');
const publicRouter = require('./routes/public');
const resultsRouter = require('./routes/results');

const app = express();
app.use(express.json());
app.use(requireAuth); // Replit Auth — unauthenticated users have req.user = null

// Public routes (no auth required — share code acts as access token)
app.use('/api', publicRouter); // GET/POST /api/s/:shareCode

// Authenticated creator routes
app.use('/api/surveys', surveysRouter);
app.use('/api/surveys', resultsRouter);

// Serve React frontend
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});

// IMPORTANT: bind to 0.0.0.0 for Replit (not localhost)
app.listen(5000, '0.0.0.0', () => console.log('Surveys app running on port 5000'));
```

> Pro tip: To deploy: click the Deploy button in the Replit toolbar and choose Autoscale. Set the run command to node server/index.js. Autoscale handles traffic spikes when a survey link gets shared widely, then scales back to zero during quiet periods.

**Expected result:** The app deploys to a public URL. The public survey form at /s/:shareCode works without login. The creator dashboard requires Replit Auth login.

## Complete code example

File: `server/routes/public.js`

```javascript
const express = require('express');
const { db } = require('../db');
const { surveys, questions, responses, answers } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');

const router = express.Router();

// GET /api/s/:shareCode — public survey (no auth)
router.get('/s/:shareCode', async (req, res) => {
  const [survey] = await db.select().from(surveys)
    .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));
  if (!survey) return res.status(404).json({ error: 'Survey not found or not active' });

  const questionList = await db.select().from(questions)
    .where(eq(questions.surveyId, survey.id))
    .orderBy(questions.position);

  // Don't expose creator_id to public
  const { creatorId: _, ...publicSurvey } = survey;
  res.json({ ...publicSurvey, questions: questionList });
});

// POST /api/s/:shareCode/submit — submit response (no auth required)
router.post('/s/:shareCode/submit', async (req, res) => {
  const [survey] = await db.select().from(surveys)
    .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));
  if (!survey) return res.status(404).json({ error: 'Survey not found' });

  const { answersMap } = req.body;
  if (!answersMap || typeof answersMap !== 'object') {
    return res.status(400).json({ error: 'answersMap is required' });
  }

  const questionList = await db.select().from(questions)
    .where(eq(questions.surveyId, survey.id));

  // Validate required questions
  const missing = questionList.filter(q => {
    if (!q.isRequired) return false;
    const val = answersMap[q.id];
    return val === undefined || val === '' || (Array.isArray(val) && val.length === 0);
  });
  if (missing.length > 0) {
    return res.status(400).json({ error: 'Required questions missing', missingIds: missing.map(q => q.id) });
  }

  const [response] = await db.insert(responses).values({
    surveyId: survey.id,
    respondentId: survey.allowAnonymous ? null : (req.user?.id || null),
  }).returning();

  const answerRows = Object.entries(answersMap)
    .filter(([qId]) => questionList.some(q => q.id === parseInt(qId)))
    .map(([qId, value]) => ({
      responseId: response.id,
      questionId: parseInt(qId),
      value: Array.isArray(value) ? JSON.stringify(value) : String(value),
    }));

  if (answerRows.length > 0) await db.insert(answers).values(answerRows);

  res.status(201).json({ responseId: response.id });
});

module.exports = router;
```

## Common mistakes

- **Activating a survey before adding questions** — If you set status = 'active' without any questions, respondents see an empty survey form, which is confusing and collects no useful data. Fix: Only allow status = 'active' if the survey has at least one question. Add a check in the PUT /api/surveys/:id route: if the new status is 'active', count the survey's questions and return 400 if count === 0.
- **Storing checkbox answers as comma-separated strings** — Comma-separated values are ambiguous when an option value itself contains a comma (e.g., 'New York, NY'). Fix: Store checkbox answers as JSON arrays: JSON.stringify(['Option A', 'Option B']). Parse with JSON.parse() in the results aggregation. This handles option values with commas or special characters.
- **Loading all responses for aggregation instead of using SQL aggregation** — Fetching all answer rows into JavaScript and aggregating in a loop is slow for surveys with thousands of responses. Fix: Use GROUP BY in SQL for radio/checkbox counts: SELECT value, COUNT(*) as count FROM answers WHERE question_id = ? GROUP BY value. Only use JavaScript aggregation for text answers where SQL grouping isn't useful.
- **Not handling the case where a question has no answers in results** — If a question was added after some responses were collected, it has zero answers. Dividing by zero in average calculations or returning undefined counts causes errors. Fix: Check allAnswers.length > 0 before calculating averages. Return default values (average: null, count: 0) for questions with no answers yet.

## Best practices

- Generate share codes with a collision check — retry up to 5 times if a generated code already exists in the database.
- Store respondent IP or session ID in the responses table (even for anonymous surveys) to detect duplicate submissions.
- Use Drizzle Studio to add test survey rows and questions during development before building the frontend form renderer.
- Always validate required questions server-side in the submit route — client-side validation can be bypassed.
- Deploy on Autoscale — surveys have bursty traffic patterns when share links are posted. Autoscale handles the spike and scales back down automatically.
- Add the Replit Auth header check to creator-only routes, but explicitly allow null req.user for the public /s/:shareCode routes.
- Use JSON.stringify for checkbox answer values and JSON.parse in the aggregation code — never use comma-separated strings for multi-select answers.

## Frequently asked questions

### Do respondents need to create an account?

No. The public survey form at /s/:shareCode is fully accessible without login. Only survey creators need to authenticate via Replit Auth. If you want to prevent duplicate responses from the same person, you can optionally require login for respondents by checking req.user?.id in the submit route.

### What question types are supported?

Six types: text (short answer), textarea (long answer), radio (single choice from options list), checkbox (multiple choice from options list), rating (star rating 1-5), number (numeric input), and dropdown (single choice from a dropdown). All are stored in the same questions table with the type column determining how the frontend renders the input.

### How do I share a survey with respondents?

After creating and activating a survey (status = 'active'), the API returns a shareUrl. The URL format is https://your-app.replit.app/s/abc12345 where the 8-character share_code is unique to each survey. Share this link via email, social media, or embed it in your website.

### What Replit plan do I need?

The Free plan is sufficient for development. For a public-facing survey app with a custom URL, deploy on Autoscale (Core plan or higher). Autoscale handles traffic spikes when share links are widely distributed and scales back down during quiet periods.

### How are multiple-choice answers stored in the database?

Radio and dropdown answers are stored as plain text values matching the selected option. Checkbox answers (multiple selections) are stored as JSON arrays using JSON.stringify(['Option A', 'Option B']). The results aggregation parses checkbox answers with JSON.parse() before counting.

### Can I see individual responses, not just aggregated results?

Yes. GET /api/surveys/:id/responses returns a list of all response records. You can join with the answers table to see each respondent's individual answers. Add a response detail view to the React frontend that shows one respondent's complete answer set.

### Can RapidDev help build a custom survey platform?

Yes. RapidDev has built 600+ apps including data collection and analytics tools. They can add advanced question types, response quotas, multi-language support, or custom branding for your specific use case. Book a free consultation at rapidevelopers.com.

### How do I close a survey after collecting enough responses?

Call PUT /api/surveys/:id with { status: 'closed' }. Once closed, the GET /api/s/:shareCode route returns 404 (it only serves active surveys), so the share link stops working. Responses already collected are preserved and viewable in the results dashboard.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/polls-and-surveys
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/polls-and-surveys
