Published in


How to Build Progressive Web Apps as NFTs

Using Pinata, Progressier, OpenAI, and Netlify

Apple’s App Store has been a massive success for many developers over the years. There’s no arguing the power of its built-in distribution model. The way that Google helped website distribution through search, Apple’s App Store has helped native app distribution. But it’s come at a frustrating cost to many developers.

Apps that are perfectly healthy and not in need of updates forced to arbitrarily update or be removed. Developers having their apps flagged and removed even after a lengthy history on the app store. And of course the 15% or 30% fees.

It’s hard to imagine a world without the convenience of native apps on your phone and the ease of discovery provided by the App Store. But today, we’re going to build that world. We are going to build an email app. But not just any email app. This email app will feel native—even though it’s a web app—and it will only send you App Store rejection emails. We’ll host this app on Pinata, and for an added discoverability bump, we’ll launch it on OpenSea as an App NFT.

Getting Started

Let’s go ahead and sign up for a free Pinata account. You’ll be able to upload your app and your metadata about the app to Pinata which will be useful later. At the end, as a bonus, if you upgrade to a paid account, we’ll walk through how you can add a simple script to deploy updates to your app and keep them hosted on a custom domain.

You will also need a free Netlify account as this is where we’ll be deploying our serverless functions that help power our app. You can sign up to Netlify here.

Finally, we’re going to use an awesome service called Progressier to make our app into a progressive web app (PWA) without us having to manage the code for this. Progressier is a paid service, but you can sign up for a 14-day free trial. If you don’t like it, you can cancel. And at the end of this article, I’ll link to a tutorial on creating a PWA.

Once you’ve signed up for those accounts, we can move on to coding. You’ll need the following:

  • Node.js version 12 or above
  • NPM
  • A good text editor

We’re going to build this app in React, so the first step is to fire up your command line tool, change into the directory where you keep all your top-secret projects, and run the following command:

npx create-react-app email-rejections

When the command you ran is complete, switch into the new directory for your app:

cd email-rejections

You can open the folder in the text editor of your choice, but we’re not going to be doing much here right now. We’ll come back to it in a bit.

Building Our Server(less) Backend

How are we going to build an app that generates new App Store rejections in an email inbox dedicated exclusively to that masochistic pursuit? We’re going to use AI, of course. OpenAI’s GPT-3 is now generally available, and they have a decent free tier that you can experiment with. You can sign up here.

We’ll be using the text completion API. In order to use this API, we’ll need to have a server to run our requests. Or better yet, we’ll need a serverless function! Remember, we signed up for a free Netlify account. Let’s use that.

You’ll need to install the Netlify CLI. You’ll want to follow their dedicated guide here to install the CLI. Once the CLI is installed, you can log in with the following command:

netlify login

This will prompt you to log in through the web interface, and once done, you’ll be good to go at the command line.

Once you’ve logged in, you can create your first serverless function. To do so, we need to tell Netlify where to put the function. We can use a simple configuration file to do this. At the root of your project, add a file called netlify.toml and put the following in it:

functions = "./functions"

Create a folder in the root of your project called functions then we can use the CLI to create our first serverless function. From the command line, make sure you are in the root of your project folder, then run:

netlify functions:create --name email-rejections

You’ll be asked to choose from a template. Just choose the hello, world template. This will create a new function inside thefunctions folder for your project. If you open the functions folder, you’ll see our serverless function has its own folder and its own file. Open the file, and you’ll see a very simple hello, world function.

We’ll probably want to test our serverless function locally. Fortunately, the Netlify CLI has us covered. If we run the following, both our React app and our serverless function will be served locally

netlify dev

We don’t care about the React app yet, so we need to know where to find our serverless function and how to call it. If you weren’t paying close attention in the command line, you would have easily missed the message printed indicating where the serverless functions were running. Luckily, you have this guide. Serverless functions are running here:


We can test our function in a tool like Postman or by using cURL from the command line. Using cURL, you can run:

curl http://localhost:8888/.netlify/functions/email-rejections

You should see Hello World printed. We’ve got a working function! Let’s modify it and start making it send us App Store rejection emails.

Integrating OpenAI’s GPT-3

In order to use GPT-3 to give us AI-generated App Store rejections, we will first need an API key. Log into your account and go to the API Keys page. Once you’ve generated a key, you’ll want to keep it somewhere safe. We can store this as an environment variable on Netlify. The beauty of this is that Netlify gives us access to these variable during development as well.

First, we need to create a new project on Netlify. From the root of your project folder in the command line, run:

netlify init

Complete the prompts and this should create a new site. While we will only be using the serverless functions part of the site, this step is important. If it fails, you can alternatively try running:

netlify deploy

Once done, go to and find the new site. Click on Deploys, then click on Deploy Settings. Then find the Environment Settings:

There, you can add an Environment Variable and label it as OPEN_AI_KEY.

Now, we can use this in our code. But what is our code actually going to be doing? Our serverless function is going to make an API call to the OpenAI text completion API. We’re going to tell the AI to generate us 5 email rejections at a time. Once the user has viewed all 5 emails, they can generate more once per day. This will require a combination of local storage on the web app and access to the serverless function.

Let’s write the serverless function. We’ll be making use of the OpenAI API Reference Docs here. Replace the entire contents of your email-rejections.js file with this:

You’ll need to install axios for this to work, so do that now from the root of your project:

npm i axios

Now, let’s take a look at the code. This code randomly chooses how many rejection emails to return (a number between 1 and 3. It uses the text completion API with the following prompt:

Write an email rejection from the App Store that gives a reason for the app's rejection.

You can tweak the prompt to your satisfaction. The serverless function will then return those completed messages which will form the emails we’ll display to the user.

You can test your serverless function with the following cURL command:

curl --location --request GET 'http://localhost:8888/.netlify/functions/email-rejections'

We’re not going to be adding authentication for this API, but because OpenAI does eventually cost money, you’d want to do so before shipping a production app.

Our serverless function is done. If you like it, you can deploy it, by running the following command:

npm run build && cd build && netlify deploy

When complete, your serverless function will be available at your site’s URL with the following appended: /.netlify/functions/email-rejections

I think we’re ready to build our UI!

Building The UI

The whole point of this blog post was to teach you how to build a web app that feels like it’s a native app, host it on IPFS, and bypass the problems that a walled garden like the Apple App Store presents. Did I get carried away with the joke of an app that displays AI-generated App Store rejections? Yes. Do I regret it? No.

Let’s get to the stuff you actually came here to build—a progressive web app. To do that, we first have to build our app. Then we can progressify it.

We’re going to keep this insanely simple. No routing or anything like that. We’ll have components, and we’ll have a container. We’ll want the app to function like a native app which means we need to check how the app is being accessed. We’ll render different views if the app is accessed on a desktop versus mobile, and we’ll even be able to render different views if the app is accessed in standalone mode (the mode that feels native) or through the mobile web browser.

If you open up the src/App.js file, we can begin. We’re going to replace everything in there, so feel free to remove it all and add the following:

Many developers will correctly tell you that the isMobile function we’re using is not ideal. It uses the userAgent for the browser, and this is configurable by the user, so it’s not totally reliable. For our purposes, it will work just fine, though.

We detect if the user is on a mobile device, and then we render a DesktopView or a MobileView. Let’s start with the DesktopView as it will be the simplest.

Create a components folder nested in the src folder. Then, create a file called DesktopView.js. Inside that file, add the following:

Sure, people could use this app on a desktop, but guess what? We’re not letting them. This is a mobile world, and we’re building for it.

Now that we have the DesktopView done, let’s move on to the MobileView. This is going to be more of a container view. Within the components folder, create a new file called MobileView.js. Add the following to that file:

Here, we are checking to see how the app has been accessed when the page loads. The standalone mode indicates that the app has been installed to the user’s home screen and they are accessing it there. That’s what we want. Based on whether the app is being accessed in standalone mode or not, we render either the AppContainer or the InstallScreen.

While we could let the user use the app on the mobile web browser and not in standalone mode, we lose some native functionality and some of the experience. So, let’s force an install. We’ll create the InstallScreen now.

In the components folder, add a file called InstallScreen.js. Then add the following to that file:

You can, of course, customize the messaging however you want. But you’ll notice my text mentions a hover button in the bottom-right? You don’t have that yet, but you will. This is part of using Progressier to make our app a PWA.

Now, let’s build for people who have installed the app and are accessing it in standalone mode. We’ll need to start with our AppContainer component. Go ahead and create the file AppContainer.js in the components folder. This container will hold all of the components that make up our mail app. Let’s enumerate them here for planning purposes:

  1. Top Nav
  2. Inbox With Count
  3. Individual Emails in List
  4. Email Contents

Let’s start with our Top Nav. In the components folder, create a file called TopNav.js. Inside that file, add the following:

This Top Nav component is pretty useless. It’s just visual since we’re not going to wire up the buttons in it. It consists of some SVGs from Heroicons. Those SVGs are a menu icon, a search icon, and a create email icon. We also sandwiched in the word “Inbox” just to make sure people know where they’re at.

Ok, the Top Nav is done. Let’s build the Inbox. In the components folder, create a file called Inbox.js. Inside that file, add the following:

This is by far our largest file. With 172 lines of code, it might feel a little intimidating, but it should but your mind at ease to know that about 60 of those lines are code just to detect a pull down swipe on mobile.

Let’s run through the file, because it’s actually not all that bad. After setting up some state variables, we are quickly jumping straight into the code to detect and handle swipes. Again, this code is just there to mimic the native mobile app experience of pulling down on your screen to fetch new content.

Once we get past that, we have two useEffect hooks. The first one fetches emails from localStorage. For a simple app like this, storing everything in localStorage makes sense. The second useEffect hook is adding event listeners to track swipe events. This allows us to know if the user has pulled down to refresh.

Next, we have our loadEmails function. This function just loads emails from localStorage and stores them in state. We track how many of those emails are unread as well.

Our fetchMoreEmails function makes use of our serverless backend. It makes a request to retrieve new rejection emails. It then combines these new responses with the original emails in the inbox.

Next we have the UI code. We check if the inbox is empty. If so, we render a nice empty state and prompt the user to load new emails. If there are emails, we map over them and render an Email component for each. We also show in the UI the total number of unread emails and if there are 0 unread, we allow the user to click a button to fetch more emails?

See, that file wasn’t so bad after all. We need to build our Email component now. Let’s create a new file in the components folder called Email.js. In that file, add the following:

This file is pretty straight forward. It is rendering a preview of each email (subject, sender, date). It is then tracking clicks on the email. If a user clicks, the full screen view of the email is pulled up with the email body included. We’re once again, using Heroicons in the email body view to allow the user to go back and to delete.

The toggleEmail function handles opening the email and going back. When it’s clicked, the email is marked as read. The deleteEmail function does what it says—deletes the email.

Ok, last thing to code up (and this will be simple) is the AppContainer. We never actually added code to it because it was dependent on components we hadn’t yet built. Let’s fix that.

Open up the AppContainer.js file and add the following:

Easiest file of the whole tutorial! Now, you’re not going to easily be able to test this app with the code as is because we haven’t enabled any standalone functionality, and the app expects standalone functionality.

To test, find your MobileView.js file and change the check for standalone to !standalone. We’ll need to remember to switch this back, but at least for now, we can test in the desktop web browser.

Run the app with npm run start. Then open http://localhost:3000. You’ll need to open the developer tools window of your browser and change it to mobile view:

Now, you can test the app all you want. When you’re done, just remember to flip your !standalone variable back to standalone.

Making It Progressive (And Feel Native)

Before we go any further, it’s important to understand what a progressive web app is (pwa). Progressive web apps, as the name confusingly tries to suggest, adds functionality progressively depending on the user’s device capabilities. For example, if you have a device that support web notifications, those will work, but they won’t work on a device that doesn’t support such notifications. You can read more about PWAs here.

In order to make our app a progressive web app that can be installed on the user’s device and take advantage of additional functionalities, we need the following:

  1. A URL where the app lives
  2. A home screen image

I’ll let you design an image for the home screen icon. Go nuts with it. Have fun. What we’ll focus on here is deploying the app. We’re going to be deploying this app to IPFS using Pinata. Why IPFS? It’s distributed, ties in to the open web ethos, and support version control out of the box. Pinata makes all of this easier to manage, too. Wait, but what is IPFS? It’s a peer-to-peer storage protocol with open data as its primary ethos. No more walled gardens (see how this aligns with the idea of breaking out of the App Store?).

Let’s use our Pinata account to deploy the app. We are going to take the simple approach to deploying our app, but we could set up an automatic deployment pipeline through code as well.

Before building and uploading our app, we’ll need to make a change to the package.json file. You’ll need to add the following line:

"homepage": "./",

This can go towards the top of the file under version. Now, we’re ready to build the app and upload it. Run the following command from the root of the project:

npm run build

When this is done, a build folder will be created. That’s what we’re going to upload to Pinata. Log into your Pinata account, and click the Upload button. Choose folder, then find the folder.

To find this project easily later, give it a name, then complete the upload.

Ok, we’re almost done. You can load the app now just by clicking on the link. Note: public IPFS gateways are slower and heavily rate-limited. If you want the performance you’ve come to expect from the web, this is your chance to consider upgrading to a paid Pinata plan, getting a Dedicated Gateway, and setting a custom domain for your gateway and app.

With that done, we just need to use Progressier to make this thing a progressive web app. Sign into your Progressier account, and create a new app. You’ll be promoted to select the tool you used to build the app. In this case, we used React.

Next, you’ll need to give your app a name and a URL where it lives. The URL should be the gateway link where you opened the app. If you’re using a Dedicated Gateway and have a custom domain, you can set your app to be served from the root of the domain without the IPFS CID (this is what I’m doing in the example below instead of…). Don’t worry too much about this URL. We can change it later.

Next, Progressier will walk you through some tasks to complete. Work through each one, following the guides closely.

For the script, manifest, and service worker, we’ll need to update our app code. Follow the steps for these (put the progressier.js file in the public folder of your project). Once you’re done with all the steps, your app is ready to be re-built and re-uploaded to Pinata.

Why the re-build and re-upload? We needed a starting URL for Progressier. So, our first upload got us that. But we’ve changed the code. Because IPFS is immutable, and because we have made changes, we need to upload a new build (just like any other app hosting service would require). So, go ahead and run npm run build again. Then, upload the new build folder to Pinata.

When you’re done, you can click the link in your Files page to open the app. Copy that link and head back to Progressier. Go to the App Manifest section and open Name and Domain. This is where you can set the new domain and save your changes.

Now, if you visit your app on a mobile device, you should see an install button in the bottom right. Click that then follow the steps to install. You’ll have a native looking and feeling version of your app installed on your device’s home screen.

Open the app up, and you’ll see the experience does not feel like a web browser experience. It feels native. This is the point. Progressive web apps enable this and much more.

If you’d like to experience the version I built with CSS and all, it’s here:

You can also find the source code here.

Now that you’ve learned how easy it is to make PWAs, go out and experiment. Build a pipeline for your app deployments to Pinata using the API, add a custom domain, build more complex apps. Find the thing you wanted to do through a native mobile app and see if it might fit as a PWA.

And what about discovery? The App Store provides great discovery mechanisms. While there are PWA app stores, maybe there’s a better way to enable discoverability. The way we built this app aligns nicely with the concept of an App NFT. You could extend this further and deploy the app as an NFT and it could be discoverable on NFT marketplace. In the future, there may even be NFT marketplaces devoted entirely to App NFTs.

Go explore!



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store