How I built a system to upload restaurant menus and generate QR codes that never change

mtt87 profile image Mattia Asti ・5 min read

I built this prototype to help restaurant avoid handling physical menus to their clients during the COVID-19 pandemic situation which would need to be sanitised afterwards.
QR codes that redirects to the online PDF menu can be printed once as they never change and clients can scan them with their smartphones while sitting at the table.
NB: I was targeting the Italian market so the UI is all in Italian.

First steps

  1. Bought the domain https://menu-qr.tech/ from Vercel (formerly Zeit) where I could easily manage DNS and connect the frontend repo for automatic deployments

  2. Created a new project on Heroku where I got a node dyno and a Postgres database connection, all on the free tier.

  3. Created a bucket on AWS S3 named view.menu-qr.tech, configured it to be public accessible as that's where I would upload the menus and put Cloudfront in front of it to have a CDN for faster loads. I've also added the relevant DNS CNAME records to Vercel in order to associate the subdomain with the Cloudfront distribution.

  4. I initially thought about adding Stripe for paid subscriptions so I've registered, got my development key and verified myself.

  5. Registered a new project with Auth0 to handle the passwordless authentication.

  6. Registered and connected the domain with Mailgun in order to send transactional and authentication emails.

How does it work?

The user once authenticated can upload a menu, at this point a few things happen:

  • the PDF menu is uploaded on S3, I put a timestamp on it in order to avoid overwriting existing menus as I want them to be immutable but still keep track of the file name as it can be handy.
  • a new Upload entry is created in the DB, generating a UUID and saving the S3 url and the path where the file is located plus other info.
  • a QR code is generated on demand, pointing at the url https://view.menu-qr.tech/?id={{UUID}} that will never change for this menu

At that point a customer can scan that QR code which will point to the view.menu-qr.tech/?id={{UUID}} page that will show a loading spinner and make a GET request to the API to fetch the correct URL where the PDF menu can be viewed, using the Cloudfront CDN url rather than S3.

The restaurant owner can go and update the menu anytime on the dashboard, making a new upload that will update the S3 url reference on the DB, allowing the final customer to view the updated menu still using the old QR code (no need to print it again).

The project involved 3 repos:

Web app (https://menu-qr.tech/)

It's a SPA built with create-react-app, using:
  • Auth0 to handle passwordless authentication
  • Rebass for the UI primitives with a custom basic theme.
  • SWR for data fetching Once the user is logged in they can see their dashboard where they can create a restaurant and upload a menu. I connected this repo to Vercel so every time I pushed the code to master it automatically built and deployed the latest version. I used react-icons and https://undraw.co/illustrations to make it nicer.

Server (https://api.menu-qr.tech/)

Built with node using express, where I defined all the routes for CRUD operations, persisting data on a Postgres database using Sequelize as ORM to be quicker.
The server is also handling all the image uploading to S3 using multer, here is a snippet of how it's done
const fileSize = 1024 * 1024 * 5; // 5mb

const upload = multer({
  limits: {
  fileFilter: (req, file, callback) => {
    const ext = path.extname(file.originalname);
    if (ext !== '.png' && ext !== '.jpg' && ext !== '.pdf' && ext !== '.jpeg') {
      callback(new Error('Only PDF or images'));
    callback(null, true);
  storage: multerS3({
    bucket: 'view.menu-qr.tech',
    acl: 'public-read',
    contentType: multerS3.AUTO_CONTENT_TYPE,
    key: (req, file, cb) => {
      // append timestamp to avoid overwriting
      cb(null, `${file.originalname}_${Date.now()}`);

I like Sequelize as it can make your life easier in these small projects, here is where I defined the tables and associations

const db = {
  User: sequelizeInstance.import('./User.js'),
  Restaurant: sequelizeInstance.import('./Restaurant.js'),
  Upload: sequelizeInstance.import('./Upload.js'),


module.exports = db;

Then you can easily load a user restaurant's and the their uploads

const data = await db.User.findByPk(userId, {
    include: [
        model: db.Restaurant,
        include: db.Upload,

I've used qrcode package to generate QR codes on demand which is nice because it supports streams, no need to save/read data on the disk.

app.get('/view-qr/:uploadId', async (req, res) => {
  const { uploadId } = req.params;
  const url = `https://view.menu-qr.tech/?id=${uploadId}`;
  QRCode.toFileStream(res, url, {
    width: 512,
    margin: 0,
    color: {
      dark: '#000',
      light: '#fff',

There is already Stripe built in supporting subscriptions management and handling webhooks for client-side checkout events, and also the logic to give users a trial period and expire with cron jobs.

Menu loader page (https://view.menu-qr.tech/)

This is a simple index.html page that is used to show a spinner and redirect the user to the menu or show an error message.

It's being deployed at https://view.menu-qr.tech/?id= automatically with Vercel, here is the simple configuration and the page code.


  "version": 2,
  "routes": [{ "src": "/(.*)", "dest": "/index.html" }]


<html lang="en">
    body {
      font-family: sans-serif;
    <div id="root" style="padding: 24px; text-align: center;">
      <div class="loading" />
      const urlParams = new URLSearchParams(window.location.search);
      const id = urlParams.get('id');
        .then((res) => {
          if (res.status === 403) {
            document.getElementById('root').innerHTML = 'Subscription expired';
          if (res.ok) {
            res.json().then((json) => window.location.replace(json.url));
          throw new Error('fail');
          () =>
            (document.getElementById('root').innerHTML = 'Error loading'),

Right after building this I realised there are already solutions that are more complete and supported by existing companies so I decided to stop the project and open-source it.

It was a good exercise and I hope it can be useful for others.

Thanks for reading 😀

Posted on by:


markdown guide