Skip to main content

Roadmap Voting App with Serverless Redis

info

We have developed an advanced version of Roadmap Voting App where users should log in to request or vote for a new feature. See it live version in Upstash Roadmap. See the blog post to learn about it. The below example allows users to request features anonymously.

In this tutorial we will write a single page application which uses Redis as state store in a Next.js application.

The example is a basic roadmap voting application where users enter and vote for feature requests. You can check the complete application in Upstash Roadmap page

Deploy Yourself#

You can check the source code of the complete application here. Thanks to Upstash&Vercel integration, you can deploy the application yourself with zero cost/code by clicking below: Deploy with Vercel

Create Next.js Project#

We will use Next.js as web framework. So let's create a next.js app and install the redis client first.

npx create-next-app nextjs-with-redis

npm install ioredis

index.js#

Our application will be a single page. We will list the features with their order of votes. There will be 3 actions available for the page user:

  • The user will suggest a new feature.
  • The user will vote up an existing feature.
  • The user will enter their email to be notified of a release of any feature.

The below are the parts that handles all those. If you want to check the full page see here

import Head from 'next/head'import { ToastContainer, toast } from 'react-toastify';import * as React from "react";
class Home extends React.Component {    ...    refreshData() {        fetch("api/list")            .then(res => res.json())            .then(                (result) => {                    this.setState({                        isLoaded: true,                        items: result.body                    });                    this.inputNewFeature.current.value = "";                },                (error) => {                    this.setState({                        isLoaded: true,                        error                    });                }            )    }
    vote(event, title) {        const requestOptions = {            method: 'POST',            headers: {'Content-Type': 'application/json'},            body: JSON.stringify({"title": title})        };        console.log(requestOptions);        fetch('api/vote', requestOptions)            .then(response => response.json()).then(data => {                console.log(data)                if(data.error) {                    toast.error(data.error, {hideProgressBar: true, autoClose: 3000});                } else {                    this.refreshData()                }        })    }
    handleNewFeature(event) {        const requestOptions = {            method: 'POST',            headers: {'Content-Type': 'application/json'},            body: JSON.stringify({"title": this.inputNewFeature.current.value})        };        fetch('api/create', requestOptions)            .then(response => response.json()).then(data => {            if(data.error) {                toast.error(data.error, {hideProgressBar: true, autoClose: 5000});            } else {                toast.info("Your feature has been added to the list.", {hideProgressBar: true, autoClose: 3000});                this.refreshData()            }        });        event.preventDefault();    }
    handleNewEmail(event) {        const requestOptions = {            method: 'POST',            headers: {'Content-Type': 'application/json'},            body: JSON.stringify({"email": this.inputEmail.current.value})        };        console.log(requestOptions);        fetch('api/addemail', requestOptions)            .then(response => response.json()).then(data => {            if(data.error) {                toast.error(data.error, {hideProgressBar: true, autoClose: 3000});            } else {                toast.info("Your email has been added to the list.", {hideProgressBar: true, autoClose: 3000});                this.refreshData()            }        });        event.preventDefault();    }}export default Home;

APIs#

With Next.js, you can write server-side APIs within your project. We will have 4 apis:

  • list features
  • vote a feature
  • add a new feature
  • add email

Now let's examine these API implementations:

list.js#

The list API connects to the Redis and fetches feature requests ordered by their scores (votes) from the Sorted Set roadmap.

import {fixUrl} from "./utils";import Redis from 'ioredis'
module.exports = async (req, res) => {    let redis = new Redis(fixUrl(process.env.REDIS_URL));    let n = await redis.zrevrange("roadmap", 0, 100, "WITHSCORES");    let result = []    for (let i = 0; i < n.length - 1; i += 2) {        let item = {}        item["title"] = n[i]        item["score"] = n[i + 1]        result.push(item)    }
    redis.quit();
    res.json({        body: result    })}

create.js#

This API connects to the Redis server and add a new element to the sorted set (roadmap) . We use "NX" flag together with ZADD, so a user will not be able to overwrite an existing feature request with the same title.

import Redis from 'ioredis'import {fixUrl} from "./utils";
module.exports = async (req, res) => {    let redis = new Redis(fixUrl(process.env.REDIS_URL));    const body = req.body;    const title = body["title"];    if (!title) {        redis.quit()        res.json({            error: "Feature can not be empty"        })    } else if (title.length < 70) {        await redis.zadd("roadmap", "NX", 1, title);        redis.quit()        res.json({            body: "success"        })    } else {        redis.quit()        res.json({            error: "Max 70 characters please."        })    }}

vote.js#

This API updates (increments) the score of the selected feature request. It also keeps the IP addresses of the user to prevent multiple votes on the same feature request.

import Redis from 'ioredis'import {fixUrl} from "./utils";
module.exports = async(req, res) => {    let redis = new Redis(fixUrl(process.env.REDIS_URL));    const body = req.body    const title = body["title"];    let ip = req.headers["x-forwarded-for"] || req.headers["Remote_Addr"] || "NA";    let c = ip === "NA" ? 1 : await redis.sadd("s:" + title, ip);    if(c === 0) {        redis.quit();        res.json({            error: "You can not vote an item multiple times"        })    } else {        let v = await redis.zincrby("roadmap", 1, title);        redis.quit();        res.json({            body: v        })    }}

addemail.js#

This API simply adds the user's email to the Redis Set. As the Set already ensures the uniqueness, we only need to check if the input is a valid email.

import Redis from 'ioredis'import {fixUrl} from "./utils";
module.exports = async(req, res) => {    let redis = new Redis(fixUrl(process.env.REDIS_URL));
    const body = req.body;    const email = body["email"];
    redis.on("error", function(err) {        throw err;    });
    if (email && validateEmail(email) ) {        await redis.sadd("emails", email );        redis.quit()        res.json({            body: "success"        })    } else {        redis.quit()        res.json({            error: "Invalid email"        })    }}
function validateEmail(email) {    const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;    return re.test(String(email).toLowerCase());}

css and utils#

index.css helps page to look good and utils.js fixes common mistakes on Redis URL.

Notes:#

  • If you deploy this application with Vercel; Vercel runs AWS Lambda functions to back the API implementations. For best performance choose the the same region for both Vercel functions and Upstash cluster.
  • You can access your database details via Upstash Console