DEV Community

Cover image for How to Build a Strawpoll-Like Voting System API with Node + Express
Razvan
Razvan

Posted on • Originally published at learnbackend.dev

How to Build a Strawpoll-Like Voting System API with Node + Express

Welcome back to Code in Action, the series where we build practical backend projects, step by step.

In this tutorial, we're going to build a simplified version of Strawpoll — the popular online service that lets users create quick polls, share them with others, and collect votes in real time.

Our API will include 3 endpoints:

  • A GET /poll/:id for retrieving polls.
  • A POST /poll/create for creating polls.
  • A POST /poll/vote for voting in polls.

Along the way, we'll implement an in-memory SQL-like database to store our data, and a schema validator middleware to ensure the data of every incoming HTTP request is clean and predictable.

Ready? Let's build!

Step 1: Set Up the Project

Let's create and enter the project's directory named strawpoll-lite.

mkdir strawpoll-lite
cd strawpoll-lite
Enter fullscreen mode Exit fullscreen mode

Create the package.json file of the project.

npm init -y
Enter fullscreen mode Exit fullscreen mode

And install both:

  • The express package for creating web servers.

  • The joi package for creating object validation schemas.

npm install express joi
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Database Object

📚 A poll is a simple survey where users can choose one option from a list of possible choices, and each choice submitted by a user is recorded as a vote.

With that in mind, let's create a new file named database.js, that exports an object literal as an in-memory database, that will store the polls, their choices, and the user votes.

// File: database.js

module.exports = {
  _indexes: {
    polls: 1,
    choices: 1,
    votes: 1,
  },
  tables: {
    polls: [],
    choices: [],
    votes: [],
  },
};
Enter fullscreen mode Exit fullscreen mode

Where:

  • tables is an object used to store poll, choice, and vote objects, each identified by an index, which is a unique, positive integer.
  • _indexes is an object used to keep track of the indexes in each table.

If you aren't familiar with SQL-like databases, a table is a related collection of records (or entries), where each record is individually identified by a unique property — usually an integer called an index.

This index is often defined as self-incrementing, which means that every time a new record is added to a table, its index value is incremented by 1.

To mimic this behavior, we'll manually use and update the corresponding _indexes property of each record type, every time we add a new one into its corresponding tables object.

Here's for example what the database object would look like if it contained 1 poll with 2 choices and 1 vote:

// File: database.js

module.exports = {
  _indexes: {
    polls: 2,
    choices: 3,
    votes: 2,
  },
  tables: {
    polls: [{
      id: 1,
      title: 'Which one tastes better?',
      closing_date: null,
      result_visibility: 'public',
    }],
    choices: [{
      id: 1,
      poll_id: 1,
      value: 'Pizza',
    }, {
      id: 2,
      poll_id: 1,
      value: 'Burger',
    }],
    votes: [{
      id: 1,
      poll_id: 1,
      choice_id: 2,
      user_name: 'Razvan',
      user_ip_address: '::ffff:127.0.0.1',
    }],
  },
};
Enter fullscreen mode Exit fullscreen mode

Below is a breakdown of each record type.

The poll object

This object represents a poll.

{
  id: <integer>,
  title: <string>,
  closing_date: <timestamp|null>,
  result_visibility: <"public"|"public_end"|"public_vote"|"private">
}
Enter fullscreen mode Exit fullscreen mode

Where:

  • id: The poll's index, derived from the database._indexes.polls property.
  • title: The poll's title.
  • closing_date: The optional poll's date and time in the Unix timestamp format after which participants cannot vote anymore.
  • result_visibility: The poll's vote results visibility, which can be either:

    • "public" to always display the votes.
    • "public_end" to display the votes once closing_date is past.
    • "public_vote" to display the votes once the user has voted.
    • "private" to keep the votes hidden.

For example:

{
  id: 1,
  title: 'Which one tastes better?',
  closing_date: null,
  result_visibility: 'public'
}
Enter fullscreen mode Exit fullscreen mode

The choice object

This object represents a single choice related to a poll.

{
  id: <integer>,
  poll_id: <integer>,
  value: <string>
}
Enter fullscreen mode Exit fullscreen mode

Where:

  • id: The choice's index, derived from the database._indexes.choices property.
  • poll_id: The poll's index.
  • value: The choice's text value.

For example:

{
  id: 1,
  poll_id: 1,
  value: 'Pizza',
}
Enter fullscreen mode Exit fullscreen mode

The vote object

This object represents a vote for a choice.

{
  id: <integer>,
  poll_id: <integer>,
  choice_id: <integer>,
  user_name: <string>,
  user_ip_address: <string>
}
Enter fullscreen mode Exit fullscreen mode

Where:

  • id: The vote's index, derived from the database._indexes.votes property.
  • poll_id: The poll's index.
  • choice_id: The choice's index.
  • user_name: The voter's username.
  • user_ip_address: The voter's IP address.

For example:

{
  id: 1,
  poll_id: 1,
  choice_id: 2,
  user_name: 'Razvan',
  user_ip_address: '::ffff:127.0.0.1',
}
Enter fullscreen mode Exit fullscreen mode

💡 New to backend programming in Node.js?

Check out the Learn Backend Mastery Program — a complete zero-to-hired roadmap that takes you from complete beginner to job-ready Node.js backend developer in 12 months.

👉 learnbackend.dev


Step 3: Create the API Endpoints

Let's create a new file named server.js, and within it, write this minimal Express set up, where all the endpoints of the API will be attached to the pollRouter router instance and mounted to the /poll root path.

// File: server.js

const express = require('express');
const database = require('./database');

const server = express();
const pollRouter = express.Router();

// ...

server.use('/poll', pollRouter);

server.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Declare the GET /poll/:id endpoint

Let's declare a new HTTP GET endpoint responsible for retrieving an existing poll, that responds with an HTTP 501 Not Implemented for now.

// File: server.js

const express = require('express');
const database = require('./database');

const server = express();
const pollRouter = express.Router();

pollRouter.get('/:id', (req, res) => {
  res.sendStatus(501);
});

// ...
Enter fullscreen mode Exit fullscreen mode

This endpoint takes a single route parameter named id, that is a positive integer corresponding to the id property of the poll object to retrieve from the database.

Declare the POST /poll/create endpoint

Let's declare a new HTTP POST endpoint responsible for creating a new poll, that uses the Express json() middleware to parse and convert the message body of incoming requests into JSON objects, and that responds with an HTTP 501 Not Implemented for now.

// File: server.js

const express = require('express');
const database = require('./database');

const server = express();
const pollRouter = express.Router();

pollRouter.get('/:id', (req, res) => {
  res.sendStatus(501);
});

pollRouter.post('/create', express.json(), (req, res) => {
  res.sendStatus(501);
});

// ...
Enter fullscreen mode Exit fullscreen mode

The message body of incoming requests on this endpoint should have the following format:

{
  poll: {
    title: <string>
    closing_date: <timestamp|null>,
    result_visibility: <"public"|"public_end"|"public_vote"|"private">,
  },
  choices: [<string>],
}
Enter fullscreen mode Exit fullscreen mode

Where:

  • poll.title: The poll's title. Must be a string of maximum 255 characters.
  • poll.closing_date: The date and time limit after which users cannot vote anymore. Must be a Unix timestamp or null.
  • poll.result_visibility: The vote's visibility. Must be either "public", "public_end", "public_vote", or "private". If the value is "public_end", the value of poll.closing_date must be non-null.
  • choices: The poll's choices. Must be an array of strings.

Declare the POST /poll/vote endpoint

Declare a new HTTP POST endpoint responsible for voting in an existing poll.

// File: server.js

const express = require('express');
const database = require('./database');

const server = express();
const pollRouter = express.Router();

pollRouter.get('/:id', (req, res) => {
  res.sendStatus(501);
});

pollRouter.post('/create', express.json(), (req, res) => {
  res.sendStatus(501);
});

pollRouter.post('/vote', express.json(), (req, res) => {
  res.sendStatus(501);
});

// ...
Enter fullscreen mode Exit fullscreen mode

The message body of incoming requests on this endpoint should have the following format:

{
  choice_id: <integer>,
  user_name: <string>,
}
Enter fullscreen mode Exit fullscreen mode

Where:

  • choice_id: The choice's index. Must be a positive integer.
  • user_name: The user's name. Must be a string of maximum 50 characters.

Step 4: Create the validation schemas

To validate and parse the parameters and message body of incoming HTTP requests, we'll use the Joi package to create a list of validation schemas, that will then be used by a custom middleware function to either reject invalid data or forward the request to the specified endpoint's controller.

Let's create a new file named schemas.js, and within it, let's:

  1. Import the joi package.
  2. Export an object literal that contains a property named after each endpoint.
// File: schemas.js

const Joi = require('joi');

module.exports = {
  getPoll: {},
  createPoll: {},
  votePoll: {},
};
Enter fullscreen mode Exit fullscreen mode

For each of these objects, let's add a property derived from the properties of the request object of the endpoint's controller, indicating where the data to validate comes from, where:

  • params stands for route parameters
  • query stands for query string parameters
  • body stands for message body
// File: schemas.js

const Joi = require('joi');

module.exports = {
  getPoll: {
    params: null,
  },
  createPoll: {
    body: null,
  },
  votePoll: {
    body: null,
  },
};
Enter fullscreen mode Exit fullscreen mode

Define the "get poll" schema

Let's assign to the getPoll.params property a Joi object with a single property named id that corresponds to the route parameter of the HTTP GET /poll/:id endpoint.

// File: schemas.js

const Joi = require('joi');

module.exports = {
  getPoll: {
    params: Joi.object({
      id: Joi.number()
        .integer()
        .min(1)
        .max(Number.MAX_SAFE_INTEGER)
        .required(),
    }),
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Where:

  • id is a mandatory integer with a minimum value of 1 and a maximum value defined by the built-in MAX_SAFE_INTEGER constant.

Define the "create poll" schema

Let's assign to the createPoll.body property a Joi object that corresponds to the expected message body of the HTTP POST /poll/create endpoint.

// File: schemas.js

// ...

const minOneHourFromNow = (value, helpers) => {
  if (value == null) return value;

  const limit = new Date(Date.now() + 60 * 60 * 1000);

  if (new Date(value) < limit) {
    return helpers.error('date.min', { limit });
  }
  return value;
};

module.exports = {
  // ...
  createPoll: {
    body: Joi.object({
      poll: Joi.object({
        title: Joi.string()
          .max(255)
          .trim()
          .required(),
        closing_date: Joi.alternatives().conditional('result_visibility', {
          is: 'public_end',
          then: Joi.date()
            .custom(minOneHourFromNow, '>= 1h from now')
            .required(),
          otherwise: Joi.date()
            .custom(minOneHourFromNow, '>= 1h from now')
            .allow(null),
        }),
        result_visibility: Joi.string()
          .valid('public', 'public_end', 'public_vote', 'private')
          .default('public')
          .required(),
      }),
      choices: Joi.array()
        .items(Joi.string().trim().min(1))
        .min(1)
        .required(),
    }),
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Where:

  • poll.title is a mandatory string of maximum 255 characters.
  • poll.closing_date is either a timestamp that's at least one hour away from now, and is mandatory if the value of the poll.result_visibility property is "public_end", or null otherwise.
  • poll.result_visibility is a mandatory string that's either "public", "public_end", "public_vote", or "private", and that defaults to "public".
  • choices is a mandatory array of strings of minimum 1 element of 1 character.

Note that the minOneHourFromNow() helper function is used to ensure that the votes of the newly created poll don't close before at least one hour from its creation time.

Define the "vote poll" schema

Let's assign to the votePoll.body property a Joi object that corresponds to the expected message body of the HTTP POST /poll/vote endpoint.

// File: schemas.js

// ...

module.exports = {
  // ...
  votePoll: {
    body: Joi.object({
      choice_id: Joi.number()
        .integer()
        .min(1)
        .max(Number.MAX_SAFE_INTEGER)
        .required(),
      user_name: Joi.string()
        .max(50)
        .required(),
    }),
  },
};
Enter fullscreen mode Exit fullscreen mode

Where:

  • choice_id is a mandatory integer with a minimum value of 1 and a maximum value defined by the built-in MAX_SAFE_INTEGER constant.
  • user_name is a mandatory string of maximum 50 characters.

Step 5: Implement a validation middleware

Let's create a custom Express validation middleware that uses the Joi schemas we've just defined to validate and normalize the data of incoming requests on the various endpoints of our API and either:

  1. Rejects the incoming request with an HTTP 400 Bad Request if any of the values doesn't match the format define by the corresponding schema.
  2. Normalizes the values and forwards the request to the endpoint's controller.

Let's create a new file named schema-validator.js, and within it, let's:

  1. Import the schemas module.
  2. Declare a factory function that takes as parameters a configuration object and returns an Express middleware.
// File: schema-validator.js

const SCHEMAS = require('./schemas');

module.exports = (config) => (req, res, next) => {
  //
};
Enter fullscreen mode Exit fullscreen mode

Where in the config object:

  • The schema property is the name of the schema to use, including getPoll, createPoll, and votePoll.
  • The source property is the name of the request's object property to check, including params for route parameters, query for query string parameters, and body for message body.

For example:

schemaValidator({ schema: 'getPoll', source: 'params' });
schemaValidator({ schema: 'createPoll', source: 'body' });
schemaValidator({ schema: 'votePoll', source: 'body' });
Enter fullscreen mode Exit fullscreen mode

Within the middleware function, let's first respond with an HTTP 500 Internal Server Error if the specified name or source within the schemas doesn't exist.

// File: schema-validator.js

const SCHEMAS = require('./schemas');

module.exports = (config) => (req, res, next) => {
  let schema = SCHEMAS?.[config.schema]?.[config.source];

  if (!schema) {
    return res.status(500).json({ error: 'Validation schema unknown' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Invoke the validate() method of the schema to validate and normalize the values of the request's object corresponding to the specified source.

// File: schema-validator.js

const SCHEMAS = require('./schemas');

module.exports = (config) => (req, res, next) => {
  let schema = SCHEMAS?.[config.schema]?.[config.source];

  if (!schema) {
    return res.status(500).json({ error: 'Validation schema unknown' });
  }

  const { value, error } = schema.validate(req[config.source], { convert: true });
};
Enter fullscreen mode Exit fullscreen mode

Note that in order to convert string values passed as route parameters to integers, such as id, we need to set the convert option of the validate() method to true.

Finally, let's either:

  1. Respond with an HTTP 400 Bad Request if the validate() method returns an error.
  2. Add the normalized values to the request's object corresponding to the specified source, and forward the request to the endpoint's controller.
// File: schema-validator.js

const SCHEMAS = require('./schemas');

module.exports = (config) => (req, res, next) => {
  let schema = SCHEMAS?.[config.schema]?.[config.source];

  if (!schema) {
    return res.status(500).json({ error: 'Validation schema unknown' });
  }

  const { value, error } = schema.validate(req[config.source], { convert: true });

  if (error) {
    return res.status(400).json({ error: error.message, source: config.source });
  }

  req[config.source] = { ...req[config.source], ...value };

  next();
};
Enter fullscreen mode Exit fullscreen mode

Use the validation middleware

Within the server.js module, let's import the schema validation middleware, and invoke it on every route with the appropriate configuration object.

// File: server.js

const express = require('express');
const database = require('./database');
const schemaValidator = require('./schema-validator');

const server = express();
const pollRouter = express.Router();

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  res.sendStatus(501);
});

pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
  res.sendStatus(501);
});

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  res.sendStatus(501);
});

// ...
Enter fullscreen mode Exit fullscreen mode

Step 6: Implement the GET /poll/:id endpoint

Within the HTTP GET /poll/:id endpoint, let's first attempt to retrieve the poll object corresponding to the id route parameter and respond with an HTTP 404 Not Found if the poll doesn't exist.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  const poll = database.tables.polls.find(poll => poll.id === req.params.id);

  if (!poll) {
    return res.status(404).json({ error: 'Poll not found' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's declare a data object that will contain the message body of the response, and include the poll's title, and optionally the list of choices and vote summary.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  let data = {
    form: {
      title: poll.title,
      choices: [],
    },
    votes: [],
  };
});
Enter fullscreen mode Exit fullscreen mode

Let's retrieve the choice and vote objects of the related poll.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  const choices = database.tables.choices.filter(choice => choice.poll_id === req.params.id);
  const votes = database.tables.votes.filter(vote => vote.poll_id === req.params.id);
});
Enter fullscreen mode Exit fullscreen mode

Let's check if the user has already voted by checking if the client's IP address exists in the list of votes, and save the current Unix timestamp.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  const hasUserVoted = votes.find(vote => vote.user_ip_address === req.ip);

  const timestamp = Date.now();
});
Enter fullscreen mode Exit fullscreen mode

Let's iterate on every element of the choice list using a for...of loop.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  for (let choice of choices) {
    //
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's include each choice in the data object if the user hasn't voted yet.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  for (let choice of choices) {
    if (!hasUserVoted) {
      data.form.choices.push({
        id: choice.id,
        value: choice.value,
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's include the vote summary in the data object if the poll's visibility is:

  • Set to "public".
  • Set to "public_end" and the current timestamp is greater than the value of the poll's closing_date property.
  • Set to "public_vote" and the user has already voted.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  for (let choice of choices) {
    if (!hasUserVoted) {
      data.form.choices.push({
        id: choice.id,
        value: choice.value,
      });
    }

    if (
      (poll.result_visibility === 'public') ||
      (poll.result_visibility === 'public_end' && timestamp > poll.closing_date) ||
      (poll.result_visibility === 'public_vote' && hasUserVoted)
    ) {
      data.votes.push({
        value: choice.value,
        total: votes.filter(vote => choice.id === vote.choice_id).length
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Finally, let's respond to the client with an HTTP 200 OK containing the data object in the JSON format.

pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
  // ...

  res.json(data);
});
Enter fullscreen mode Exit fullscreen mode

Test the endpoint

To test this endpoint, let's add the following objects into the database.js module.

// File: database.js

module.exports = {
  _indexes: {
    polls: 4,
    choices: 7,
    votes: 2,
  },
  tables: {
    polls: [
      { id: 1, title: 'Which one tastes better?', closing_date: null, result_visibility: 'public' },
      { id: 2, title: 'Will Santa come at midnight?', closing_date: 1767221999000, result_visibility: 'public_end' },
      { id: 3, title: 'Which car is faster?', closing_date: 1767221999000, result_visibility: 'public_vote' },
    ],
    choices: [
      { id: 1, poll_id: 1, value: 'Pizza' },
      { id: 2, poll_id: 1, value: 'Burger' },
      { id: 3, poll_id: 2, value: 'Yes' },
      { id: 4, poll_id: 2, value: 'No' },
      { id: 5, poll_id: 3, value: 'Lamborghini' },
      { id: 6, poll_id: 3, value: 'Ferrari' },
    ],
    votes: [
      { id: 1, poll_id: 1, choice_id: 2, user_name: 'Jackson', user_ip_address: '::ffff:127.0.0.1' }
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

And let's start the server:

node server.js
Enter fullscreen mode Exit fullscreen mode

When querying the poll with an invalid id, it should respond with an HTTP 400 Bad Request.

$ curl -i 127.0.0.1:3000/poll/x
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 57
ETag: W/"39-mR6PkOBbhBJJAJ4fA9g4i+VgqDU"
Date: Sat, 11 Oct 2025 11:03:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"\"id\" must be a number","source":"params"}
Enter fullscreen mode Exit fullscreen mode

When querying the poll whose id equals 1, it should respond with an HTTP 200 OK containing the votes but not the choices, since the user has already voted, as recorded by the IP address ::ffff:127.0.0.1.

$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 121
ETag: W/"79-XpGX35CnKNaFzfSTxi49RnHyEFU"
Date: Sat, 11 Oct 2025 10:36:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"form":{"title":"Which one tastes better?","choices":[]},"votes":[{"value":"Pizza","total":0},{"value":"Burger","total":1}]}
Enter fullscreen mode Exit fullscreen mode

When querying the poll whose id equals 2, it should respond with an HTTP 200 OK containing the choices but not the votes, since the poll's closing date is set to the 1767221999000 timestamp, which is equivalent to 2025/12/31 at 23:59:59.

$ curl -i 127.0.0.1:3000/poll/2
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 117
ETag: W/"75-91j3tJqqn1xsMWS5VSzIOGJEtjk"
Date: Sat, 11 Oct 2025 10:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"form":{"title":"Will Santa come at midnight?","choices":[{"id":3,"value":"Yes"},{"id":4,"value":"No"}]},"votes":[]}
Enter fullscreen mode Exit fullscreen mode

When querying the poll whose id equals 3, it should respond with an HTTP 200 OK containing the choices but not the votes, since the poll's votes visibility is set to "public_vote" and the user hasn't voted yet.

$ curl -i 127.0.0.1:3000/poll/3
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 122
ETag: W/"7a-k5DedEni/OTb8wTy6MhpJ4G1opI"
Date: Sat, 11 Oct 2025 10:57:40 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"form":{"title":"Which car is faster?","choices":[{"id":5,"value":"Lamborghini"},{"id":6,"value":"Ferrari"}]},"votes":[]}
Enter fullscreen mode Exit fullscreen mode

When querying the poll whose id equals 4, it should respond with an HTTP 404 Not Found.

$ curl -i 127.0.0.1:3000/poll/4
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 9
ETag: W/"9-0gXL1ngzMqISxa6S1zx3F4wtLyg"
Date: Sat, 11 Oct 2025 10:36:25 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"Poll not found"}
Enter fullscreen mode Exit fullscreen mode

Step 7: Implement the POST /poll/create endpoint

Within the HTTP POST /poll/create endpoint, let's create a new poll object using the data parsed by the validation middleware and assign it the next id available from the indexes object, while post-incrementing it at the same time.

pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
  const poll = {
    id: database._indexes.polls++,
    ...req.body.poll,
  };
});
Enter fullscreen mode Exit fullscreen mode

Let's store the object into the database.polls table.

pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
  const poll = {
    id: database._indexes.polls++,
    ...req.body.poll,
  };

  database.tables.polls.push(poll);
});
Enter fullscreen mode Exit fullscreen mode

Let's store each choice into the choices table, and assign it the next id available, while post-incrementing it at the same time.

pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
  // ...

  for (let choice of req.body.choices) {
    database.tables.choices.push({
      id: database._indexes.choices++,
      poll_id: poll.id,
      value: choice,
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

And finally, let's respond with an HTTP 200 OK containing the newly created poll's id.

pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
  // ...

  res.json({ poll_id: poll.id });
});
Enter fullscreen mode Exit fullscreen mode

Test the endpoint

To test this endpoint, let's reset the object exported by the database.js module.

// File: database.js

module.exports = {
  _indexes: {
    polls: 1,
    choices: 1,
    votes: 1,
  },
  tables: {
    polls: [],
    choices: [],
    votes: [],
  },
};
Enter fullscreen mode Exit fullscreen mode

Let's kill (using CTRL+C) and restart the server.

$ node server.js
^C
$ node server.js
Enter fullscreen mode Exit fullscreen mode

When creating a poll with a missing list of choices, it should respond with an HTTP 400 Bad Request.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":null,"result_visibility":"public"}}' 127.0.0.1:3000/poll/create
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 51
ETag: W/"33-iUbkFOxzZLKsXJpI+yPu7hLKxzA"
Date: Sat, 11 Oct 2025 11:23:45 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"\"choices\" is required","source":"body"}
Enter fullscreen mode Exit fullscreen mode

When creating a poll with a result visibility set to "public_end" and a closing date set to null, it should respond with an HTTP 400 Bad Request.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":null,"result_visibility":"public_end"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 70
ETag: W/"46-ezrMA+0QQL71WIgvCuXx5h4oM1s"
Date: Sat, 11 Oct 2025 11:24:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"\"poll.closing_date\" must be a valid date","source":"body"}
Enter fullscreen mode Exit fullscreen mode

When creating a poll with a closing date set to a past timestamp, it should respond with an HTTP 400 Bad Request.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":1735722000000,"result_visibility":"public_end"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 111
ETag: W/"6f-mHVPvcnfmJfLGpoqQasL3kO5gU8"
Date: Sat, 11 Oct 2025 11:25:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"\"poll.closing_date\" must be greater than or equal to \"2025-10-11T12:25:02.121Z\"","source":"body"}
Enter fullscreen mode Exit fullscreen mode

When creating a valid poll, it should respond with an HTTP 200 OK containing the poll's id.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":1767221999000,"result_visibility":"public_end"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-cHff1Ljgd50XiLS5Skq4iqxEZCg"
Date: Sat, 11 Oct 2025 11:25:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"poll_id":1}
Enter fullscreen mode Exit fullscreen mode

When querying the poll using the id returned by the previous request, it should respond with an HTTP 200 OK containing the poll's data.

$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 119
ETag: W/"77-QQ4/koZFbRWbvl/yYAKAyB6OkZs"
Date: Sat, 11 Oct 2025 11:25:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"form":{"title":"Which one tastes better?","choices":[{"id":1,"value":"Pizza"},{"id":2,"value":"Burger"}]},"votes":[]}
Enter fullscreen mode Exit fullscreen mode

Step 8: Implement the POST /poll/vote endpoint

Within the HTTP POST /poll/vote endpoint, let's first retrieve the choice object from the database corresponding to the choice_id property of the request's message body, and respond with an HTTP 404 Not Found if it doesn't exist.

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  const choice = database.tables.choices.find(choice => choice.id === req.body.choice_id);

  if (!choice) {
    return res.status(404).json({ error: 'Choice not found' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's retrieve the poll object from the database corresponding to the id property of the retrieved choice object, and respond with an HTTP 404 Not Found if it doesn't exist.

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  // ...

  const poll = database.tables.polls.find(poll => poll.id === choice.poll_id);

  if (!poll) {
    return res.status(404).json({ error: 'Poll not found' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's check if the closing_date property of the poll object is set, and respond with an HTTP 403 Forbidden if the current timestamp is greater, which means that the poll is closed and users can't vote anymore.

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  // ...

  if (poll.closing_date && Date.now() > poll.closing_date) {
    return res.status(403).json({ error: 'Poll is closed' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's check if the client's IP address exists in the poll's vote list, and respond with an HTTP 403 Forbidden if it does, which means that the user has already voted.

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  // ...

  const hasUserVoted = database.tables.votes.findIndex(vote => vote.poll_id === choice.poll_id && vote.user_ip_address === req.ip);

  if (hasUserVoted !== -1) {
    return res.status(403).json({ error: 'User has already voted' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's insert a new vote object into the votes table, including the vote's index, the poll's index, the data from the message body, and the client's IP address.

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  // ...

  const vote = {
    id: database._indexes.votes++,
    poll_id: choice.poll_id,
    ...req.body,
    user_ip_address: req.ip
  };

  database.tables.votes.push(vote);
});
Enter fullscreen mode Exit fullscreen mode

Finally, let's respond with an HTTP 200 OK containing the id property of the vote object.

pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
  // ...

  res.json({ vote_id: vote.id });
});
Enter fullscreen mode Exit fullscreen mode

Test the endpoint

To test this endpoint, let's kill and restart the server.

$ node server.js
^C
$ node server.js
Enter fullscreen mode Exit fullscreen mode

When creating a new poll, it should respond with an HTTP 200 OK containing the poll's id.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":null,"result_visibility":"public_vote"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-cHff1Ljgd50XiLS5Skq4iqxEZCg"
Date: Sat, 11 Oct 2025 15:38:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"poll_id":1}
Enter fullscreen mode Exit fullscreen mode

When querying the poll we've just created using the id returned by the previous query, it should respond with an HTTP 200 OK containing the poll's data, including the choices but not the votes, since the poll's result visibility is set to "public_vote".

$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 119
ETag: W/"77-QQ4/koZFbRWbvl/yYAKAyB6OkZs"
Date: Sat, 11 Oct 2025 15:38:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"form":{"title":"Which one tastes better?","choices":[{"id":1,"value":"Pizza"},{"id":2,"value":"Burger"}]},"votes":[]}
Enter fullscreen mode Exit fullscreen mode

When voting for a poll with an invalid payload, it should respond with an HTTP 400 Bad Request.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":2}' 127.0.0.1:3000/poll/vote
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 53
ETag: W/"35-e+Aj2lGPB4w4JfvHCy/ykC8/7G8"
Date: Sat, 11 Oct 2025 15:53:58 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"\"user_name\" is required","source":"body"}
Enter fullscreen mode Exit fullscreen mode

When voting for a poll with a non-existent choice id, it should respond with an HTTP 404 Not Found.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":3,"user_name":"Jackson"}' 127.0.0.1:3000/poll/vote
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 28
ETag: W/"1c-1cCU5TxdKUmlTxzbj9LSDLwyjYM"
Date: Sat, 11 Oct 2025 15:56:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"Choice not found"}
Enter fullscreen mode Exit fullscreen mode

When voting for a poll with a valid payload and an existing choice id, it should respond with an HTTP 200 OK containing the newly created vote's id.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":2,"user_name":"Jackson"}' 127.0.0.1:3000/poll/vote
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-nisMVKjWYQ6K/DygNQyFrZoaFXU"
Date: Sat, 11 Oct 2025 15:38:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"vote_id":1}
Enter fullscreen mode Exit fullscreen mode

When voting again for the same poll, it should respond with an HTTP 403 Forbidden.

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":2,"user_name":"Jackson"}' 127.0.0.1:3000/poll/vote
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 34
ETag: W/"22-zN9pQ6+RFSObob4HECP2Wn+TtDc"
Date: Sat, 11 Oct 2025 15:44:59 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"User has already voted"}
Enter fullscreen mode Exit fullscreen mode

Finally, when querying the poll again, it should respond with an HTTP 200 OK containing the poll's data, including the votes but not the choices, since the user has already voted.

$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 125
ETag: W/"7d-mNH3QPy4zUlnq89V201JwkrDefI"
Date: Sat, 11 Oct 2025 15:38:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"form":{"title":"Which one tastes better?","choices":[]},"votes":[{"value":"Pizza","total":0},{"value":"Burger","total":1}]}
Enter fullscreen mode Exit fullscreen mode

Congratulations! 🎉

You now have a working Strawpoll-like API built with Node.js, Express and Joi. 🎉

Want to Learn Backend the Right Way?

✅ Run this project on your own machine - download the source code here.

🚀 If you enjoyed this article and want to go beyond snippets, check out the Learn Backend Mastery program — the complete zero-to-hired roadmap to becoming a job-ready Node.js backend developer, including:

  • 136 premium lessons across CLI, JavaScript, Node.js, MySQL, Express, Git, and more.
  • 25 full-scale projects + commented solutions
  • Visual progress tracking.
  • Exclusive student Discord channel
  • Lifetime access and all future updates

👉 Explore the Mastery program

Top comments (0)