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.
What you're building
A polls and surveys app lets you gather structured feedback without paying for Typeform or Google Forms. You control the branding, the data stays in your PostgreSQL database, and you can customize the question types and results view exactly how you need them. This is useful for product feedback, event planning, customer satisfaction, and quick team polls.
Replit Agent generates the complete Express backend and React frontend in a single prompt. The data model is flexible by design: a questions table stores all question types (text, radio, checkbox, rating, number, dropdown) and an answers table stores all responses as text values. This means adding a new question type requires zero schema changes — just update the frontend to render a different input component.
The public survey page is accessible without login — respondents don't need accounts. Only the survey creator needs to authenticate (via Replit Auth) to create surveys, view results, and close surveys. Deploy on Autoscale — survey links often get shared widely and receive traffic spikes when posted in newsletters or social media.
Final result
A fully functional survey builder where creators log in to create and analyze surveys, and respondents fill out public surveys via a share link — deployed on Replit Autoscale.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Type this into Replit Agent:2// Build a polls and surveys platform with Express and PostgreSQL using Drizzle ORM.3// Tables:4// - surveys: id serial pk, creator_id text not null, title text not null, description text,5// status text default 'draft' (enum: draft/active/closed),6// share_code text unique not null, allow_anonymous boolean default true,7// created_at timestamp default now()8// - questions: id serial pk, survey_id integer FK surveys not null,9// type text not null (enum: text/textarea/radio/checkbox/rating/number/dropdown),10// text text not null, options jsonb (array of strings for radio/checkbox/dropdown),11// is_required boolean default true, position integer not null12// - responses: id serial pk, survey_id integer FK surveys not null,13// respondent_id text (null if anonymous), submitted_at timestamp default now()14// - answers: id serial pk, response_id integer FK responses not null,15// question_id integer FK questions not null, value text not null16// Routes: POST /api/surveys (create — auto-generate share_code),17// GET /api/surveys (list user's surveys with response counts),18// PUT /api/surveys/:id (update title/description/status),19// POST /api/surveys/:id/questions (add question),20// PUT /api/surveys/:id/questions/:qid (update question),21// DELETE /api/surveys/:id/questions/:qid (remove),22// PATCH /api/surveys/:id/questions/reorder (update positions),23// GET /api/s/:shareCode (public — survey with questions),24// POST /api/s/:shareCode/submit (submit response, no auth required),25// GET /api/surveys/:id/responses (list responses),26// GET /api/surveys/:id/results (aggregated results).27// Use Replit Auth for survey creators only.28// React frontend: survey builder, public survey form (one question at a time),29// 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).
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.
1const express = require('express');2const { db } = require('../db');3const { surveys } = require('../../shared/schema');4const { eq } = require('drizzle-orm');56const router = express.Router();78// Generate a short, unique 8-character share code9function generateShareCode() {10 const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';11 return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');12}1314// POST /api/surveys — create new survey15router.post('/', async (req, res) => {16 const creatorId = req.user?.id;17 if (!creatorId) return res.status(401).json({ error: 'Login required to create surveys' });1819 const { title, description, allowAnonymous = true } = req.body;2021 // Generate unique share code (retry on collision)22 let shareCode;23 let attempts = 0;24 while (attempts < 5) {25 shareCode = generateShareCode();26 const existing = await db.select().from(surveys)27 .where(eq(surveys.shareCode, shareCode));28 if (existing.length === 0) break;29 attempts++;30 }3132 const [survey] = await db.insert(surveys).values({33 creatorId,34 title,35 description: description || null,36 shareCode,37 allowAnonymous,38 status: 'draft',39 }).returning();4041 res.status(201).json({42 ...survey,43 shareUrl: `${process.env.REPLIT_DEPLOYMENT_URL || ''}/s/${shareCode}`,44 });45});4647// GET /api/surveys — list creator's surveys with response counts48router.get('/', async (req, res) => {49 const creatorId = req.user?.id;50 if (!creatorId) return res.status(401).json({ error: 'Login required' });5152 const result = await db.execute(sql`53 SELECT s.*, COUNT(r.id) AS response_count54 FROM surveys s55 LEFT JOIN responses r ON r.survey_id = s.id56 WHERE s.creator_id = ${creatorId}57 GROUP BY s.id58 ORDER BY s.created_at DESC59 `);6061 res.json(result.rows);62});6364module.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.
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.
1const { surveys, questions, responses, answers } = require('../../shared/schema');2const { eq, and } = require('drizzle-orm');34// GET /api/s/:shareCode — get survey with questions for respondent5router.get('/s/:shareCode', async (req, res) => {6 const [survey] = await db.select().from(surveys)7 .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));89 if (!survey) return res.status(404).json({ error: 'Survey not found or not active' });1011 const questionList = await db.select().from(questions)12 .where(eq(questions.surveyId, survey.id))13 .orderBy(questions.position);1415 res.json({ ...survey, questions: questionList });16});1718// POST /api/s/:shareCode/submit — submit response (no auth required)19router.post('/s/:shareCode/submit', async (req, res) => {20 const [survey] = await db.select().from(surveys)21 .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));2223 if (!survey) return res.status(404).json({ error: 'Survey not found' });2425 const { answersMap, respondentId } = req.body;26 // answersMap: { questionId: 'answer value' or ['option1', 'option2'] for checkboxes }2728 // Validate required questions29 const questionList = await db.select().from(questions)30 .where(eq(questions.surveyId, survey.id));3132 const missingRequired = questionList.filter(q =>33 q.isRequired && !answersMap[q.id] &&34 (Array.isArray(answersMap[q.id]) ? answersMap[q.id].length === 0 : !answersMap[q.id])35 );3637 if (missingRequired.length > 0) {38 return res.status(400).json({39 error: 'Required questions not answered',40 missingQuestions: missingRequired.map(q => q.id),41 });42 }4344 // Create response record45 const [response] = await db.insert(responses).values({46 surveyId: survey.id,47 respondentId: survey.allowAnonymous ? null : (respondentId || req.user?.id || null),48 }).returning();4950 // Bulk insert answers51 const answerRows = questionList52 .filter(q => answersMap[q.id] !== undefined)53 .map(q => ({54 responseId: response.id,55 questionId: q.id,56 value: Array.isArray(answersMap[q.id])57 ? JSON.stringify(answersMap[q.id])58 : String(answersMap[q.id]),59 }));6061 if (answerRows.length > 0) {62 await db.insert(answers).values(answerRows);63 }6465 res.status(201).json({ responseId: response.id, message: 'Response submitted successfully' });66});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.
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.
1// GET /api/surveys/:id/results — aggregated results per question2router.get('/:id/results', async (req, res) => {3 const creatorId = req.user?.id;4 const surveyId = parseInt(req.params.id);56 // Verify ownership7 const [survey] = await db.select().from(surveys)8 .where(and(eq(surveys.id, surveyId), eq(surveys.creatorId, creatorId)));9 if (!survey) return res.status(403).json({ error: 'Not authorized' });1011 const questionList = await db.select().from(questions)12 .where(eq(questions.surveyId, surveyId))13 .orderBy(questions.position);1415 const totalResponses = await db.execute(sql`16 SELECT COUNT(*) as count FROM responses WHERE survey_id = ${surveyId}17 `);1819 const results = await Promise.all(questionList.map(async (q) => {20 const allAnswers = await db.select().from(answers)21 .where(eq(answers.questionId, q.id));2223 let aggregated = {};2425 if (q.type === 'radio' || q.type === 'dropdown') {26 const counts = {};27 allAnswers.forEach(a => { counts[a.value] = (counts[a.value] || 0) + 1; });28 aggregated = { type: 'counts', data: counts };29 } else if (q.type === 'checkbox') {30 const counts = {};31 allAnswers.forEach(a => {32 const selected = JSON.parse(a.value || '[]');33 selected.forEach(opt => { counts[opt] = (counts[opt] || 0) + 1; });34 });35 aggregated = { type: 'counts', data: counts };36 } else if (q.type === 'rating' || q.type === 'number') {37 const values = allAnswers.map(a => parseFloat(a.value)).filter(v => !isNaN(v));38 const avg = values.length > 0 ? (values.reduce((s, v) => s + v, 0) / values.length).toFixed(2) : null;39 aggregated = { type: 'average', average: avg, count: values.length };40 } else {41 // text / textarea — return recent samples42 aggregated = {43 type: 'text',44 samples: allAnswers.slice(-10).map(a => a.value),45 };46 }4748 return { question: q, ...aggregated, totalAnswers: allAnswers.length };49 }));5051 res.json({52 survey,53 totalResponses: parseInt(totalResponses.rows[0].count),54 results,55 });56});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.
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.
1// server/index.js — final server setup2const express = require('express');3const path = require('path');4const { requireAuth } = require('@replit/repl-auth');56const surveysRouter = require('./routes/surveys');7const publicRouter = require('./routes/public');8const resultsRouter = require('./routes/results');910const app = express();11app.use(express.json());12app.use(requireAuth); // Replit Auth — unauthenticated users have req.user = null1314// Public routes (no auth required — share code acts as access token)15app.use('/api', publicRouter); // GET/POST /api/s/:shareCode1617// Authenticated creator routes18app.use('/api/surveys', surveysRouter);19app.use('/api/surveys', resultsRouter);2021// Serve React frontend22app.use(express.static(path.join(__dirname, '../client/dist')));23app.get('*', (req, res) => {24 res.sendFile(path.join(__dirname, '../client/dist/index.html'));25});2627// IMPORTANT: bind to 0.0.0.0 for Replit (not localhost)28app.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
1const express = require('express');2const { db } = require('../db');3const { surveys, questions, responses, answers } = require('../../shared/schema');4const { eq, and, sql } = require('drizzle-orm');56const router = express.Router();78// GET /api/s/:shareCode — public survey (no auth)9router.get('/s/:shareCode', async (req, res) => {10 const [survey] = await db.select().from(surveys)11 .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));12 if (!survey) return res.status(404).json({ error: 'Survey not found or not active' });1314 const questionList = await db.select().from(questions)15 .where(eq(questions.surveyId, survey.id))16 .orderBy(questions.position);1718 // Don't expose creator_id to public19 const { creatorId: _, ...publicSurvey } = survey;20 res.json({ ...publicSurvey, questions: questionList });21});2223// POST /api/s/:shareCode/submit — submit response (no auth required)24router.post('/s/:shareCode/submit', async (req, res) => {25 const [survey] = await db.select().from(surveys)26 .where(and(eq(surveys.shareCode, req.params.shareCode), eq(surveys.status, 'active')));27 if (!survey) return res.status(404).json({ error: 'Survey not found' });2829 const { answersMap } = req.body;30 if (!answersMap || typeof answersMap !== 'object') {31 return res.status(400).json({ error: 'answersMap is required' });32 }3334 const questionList = await db.select().from(questions)35 .where(eq(questions.surveyId, survey.id));3637 // Validate required questions38 const missing = questionList.filter(q => {39 if (!q.isRequired) return false;40 const val = answersMap[q.id];41 return val === undefined || val === '' || (Array.isArray(val) && val.length === 0);42 });43 if (missing.length > 0) {44 return res.status(400).json({ error: 'Required questions missing', missingIds: missing.map(q => q.id) });45 }4647 const [response] = await db.insert(responses).values({48 surveyId: survey.id,49 respondentId: survey.allowAnonymous ? null : (req.user?.id || null),50 }).returning();5152 const answerRows = Object.entries(answersMap)53 .filter(([qId]) => questionList.some(q => q.id === parseInt(qId)))54 .map(([qId, value]) => ({55 responseId: response.id,56 questionId: parseInt(qId),57 value: Array.isArray(value) ? JSON.stringify(value) : String(value),58 }));5960 if (answerRows.length > 0) await db.insert(answers).values(answerRows);6162 res.status(201).json({ responseId: response.id });63});6465module.exports = router;Customization ideas
Question branching logic
Add a show_if column to questions storing a condition like { questionId: 2, value: 'Yes' }. The public survey page renders a question only if the referenced question's current answer matches the condition.
Email results digest
Add a notify_email column to surveys. After each submission, if notify_email is set, send a summary email via SendGrid with the number of responses and the latest answer text. Store SENDGRID_API_KEY in Replit Secrets.
Duplicate submission prevention
For non-anonymous surveys, add a unique constraint on (survey_id, respondent_id) in the responses table. The submit route returns HTTP 409 if the user has already submitted, and the frontend shows a 'You have already responded' message.
Survey response export
Add a GET /api/surveys/:id/export/csv route that queries all responses and answers and builds a CSV string with questions as column headers and one row per response. Return it with Content-Disposition: attachment; filename=survey-results.csv.
Common pitfalls
Pitfall: Activating a survey before adding questions
How to avoid: 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.
Pitfall: Storing checkbox answers as comma-separated strings
How to avoid: 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.
Pitfall: Loading all responses for aggregation instead of using SQL aggregation
How to avoid: 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.
Pitfall: Not handling the case where a question has no answers in results
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a surveys app with Express and PostgreSQL. I have a questions table with a type column (enum: text/textarea/radio/checkbox/rating/number/dropdown) and an answers table with a value text column. Help me write a Node.js function that takes a question object and an array of answer rows, and returns an aggregated result object: for radio/dropdown return {type: 'counts', data: { optionA: 5, optionB: 3 }}, for checkbox return counts by parsing JSON array values, for rating/number return {type: 'average', average: 4.2, count: 15}, for text/textarea return {type: 'text', samples: ['answer1', 'answer2']} with the 10 most recent answers.
Add CSV export to the surveys app. Add GET /api/surveys/:id/export/csv that builds a CSV string: first row = question texts as headers, subsequent rows = one per response with the answer to each question. For checkbox answers, join the JSON array with a pipe (|). Set response headers: Content-Type: text/csv, Content-Disposition: attachment; filename=survey-TITLE-results.csv. On the React frontend, add an Export CSV button next to the results chart that triggers a file download by creating a blob URL from the response.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation