Deploying an NFT on Polygon

Deploying an NFT on Polygon

This tutorial walks through using hardhat + Polygon to deploy an NFT collection, using local JSON files & images while pushing the metadata to Tableland tables.


Tableland is a web3-native database that can be used to store data in relational tables. One of the most exciting use cases is using Tableland for NFT metadata — which is a challenging problem in web3, especially for novel dynamic NFT use cases. Developers must make tradeoffs between:

  • Expensive on-chain storage with very limited query-ability
  • Centralized storage, which doesn’t enable web3 paradigms
  • Decentralized storage (e.g., IPFS), which is great for file/image storage, but immutable files (CIDs) pose a challenge for novel NFT metadata use cases

Tableland solves the web3 metadata problem with ERC721-based tables that are powered by smart contracts. Create, insert, and update tables, all using smart contracts and/or libraries built on top of Tableland smart contracts. Although not covered in this walkthrough, the metadata can also be dynamic and change based on user interactions or on-chain events. The Tableland contracts are currently deployed on Polygon mainnet & Mumbai testnet, as well as a number of other chains — this tutorial will use Polygon’s Mumbai testnet.

All in all, this tutorial will walk through:

  1. Reading NFT images locally and uploading them to IPFS using NFT.Storage
  2. Writing the CID to metadata JSON files & objects image values
  3. Transforming the NFT metadata to prepare it for a relational data (SQL statements)
  4. Creating tables & inserting metadata into Tableland tables — into two tables
“Flat” (one) table schemas are not the recommended way to create NFT metadata. Relational data opens up new doors for NFT metadata and allows for more optimal ways to store data — use two tables for metadata and join them through the NFT’s tokenURI (e.g., a “main” and “attributes” tables).

Note: If you’d like to follow along in a video walkthrough, check out this workshop.

Using Polygon

This tutorial will walk through deploying an NFT to Polygon’s testnet. What is Polygon? Polygon is a popular L2 scaling solution called a sidechain. It has low transaction costs and high transaction speed & throughput, so many developers choose Polygon when designing cost effective applications that require a high transaction throughput or speed.

For comparison, Polygon supports 7k tx/s compared to Ethereum’s 15 tx/s and ~10000x lower costs per transaction than Ethereum. It’s important to note that sidechains do use different security assumptions than the L1; it’s what allows Polygon to architect its network in a way that enables all of these benefits for developers. Nevertheless, it’s a great scaling solution.


Before getting started, be sure to do the following:

  1. A basic understanding of Ethereum/Polygon, smart contracts, Solidity, and JavaScript.
  2. Have a private key from your wallet handy, such as exporting it via MetaMask — and have saved this locally in a .env file (more on this below).
  3. Have testnet MATIC in your wallet — get some from the Polygon Mumbai faucet.
  4. Signed up for an NFT.Storage account and make a note of the API key (also placed in .env).
    1. Since Tableland is not a file storage solution, using IPFS (or persisted file solutions like Filecoin) is still a best practice — NFT.Storage is a great file “pinning” solution.
  5. Signed up for an Alchemy account for interacting with the Polygon Mumbai testnet.
  6. (Optional) Signed up for a Polygonscan account and created an API key (in .env)
NEVER share your .env file publicly — ensure that it is specified in a .gitignore file.

Example Output & Repo

See the following for the final product, which includes an NFT collection on Polygon:

Project Flow & Structure

The purpose of each file is described below. Basically, the entry point is deployTwoTables, which is ran with npx hardat run scripts/deployTwoTables.js --network "polygon-mumbai". This will use the helper prepareSql to create SQL INSERT statements, which uses metadataProcessing to read files from the images and metadata directories. Lastly, the TwoTablesNFT contract is an ERC721 NFT contract, which leverages the Tableland gateway at the NFT’s tokenURI. A relational data model is used to easily compose data at the tokenURI across a main and attributes table deployed in the deployTwoTables with all of the corresponding metadata.

The following identifies what the final structure should look like; it may be helpful to reproduce this scaffolding ahead of time and also clone the repo to get the images and metadata files:

  • images ⇒ A couple of sample images, but any images/amount can be included — these will be uploaded to IPFS. Note that these will be related to the NFT's tokenId.
  • metadata ⇒ The corresponding metadata files for each image, which lack the "image" field value (empty string by default). The metadata files will have their "image" values overwritten by the image's CID upon IPFS upload. These JSON files must have a 1:1 relationship to images, with matching names (e.g., 0.jpeg for the image, and 0 for the JSON, omitting the extension).
  • contracts ⇒ The NFT smart contract (TwoTablesNFT), which will mint tokens & allow for the baseURI to be set that points to the Tableland network. TwoTablesNFT is the "recommended" way to do things where two Tableland tables (main and attributes) are used and composed with SQL.
  • hardhat.config.js ⇒ Some useful deployment configs, including gateways to the proper Alchemy node provider on Polygon Mumbai testnets -- and also loading the private key from .env for live testnet deployment.
  • scripts:
    • metadataProcessing.js ⇒ Look for images in images, upload images to IPFS, parse the metadata files, write these CIDs to the corresponding JSON/object, and also, return the built object for metadata preparation.
    • prepareSql.js ⇒ Take the output from uploadMetadataToIpfs.js and build SQL statements.
    • deployTwoTables.js ⇒ Deploy the TwoTablesNFT contracts, using the output from prepareSql.js — and set the baseURI & tokenURI to the Tableland gateway (
    • verifyTwoTables.js ⇒ Although optional, an additional script that can be used to verify a contract on Polygonscan.
  • test ⇒ Includes some simple chai tests with ethers as well, including testing out the tokenURI is correct.
  • .env ⇒ Private variables to store locally, so do not expose these publicly; examples are provided in .env.example

Working with Metadata Files

Many coming from the world of NFTs & metadata are familiar with the ERC721 metadata format, recommended by OpenSea. Traditionally, NFTs will deploy this as a JSON file on IPFS. As such, we’ll assume a metadata JSON file has been created using this format — and the files have been provided in the repo itself:

Notice the highly structured nature of this file. There are top-level keys (name, description, image, and attributes) and objects contained in the attributes array itself. This is a key point to understand. We will be creating two tables — one that contains all of the top-level fields, and one that is dedicated to the attributes. In other words, a main table will contain column headers of name, description, and image, and a separate table named attributes will contain column headers of trait_type and value.

Also, notice how the image field is an empty string — the assumption is that you have the images (provided in the repo), but you have not taken these images and added them to your metadata itself. Namely, we’ll need to upload images to IPFS and then use these CIDs to write to the metadata itself.

Here are the example images — we’ll be using a couple of the Tableland Rigs assets:

Initial Setup

Before getting started, let’s spin up a hardhat project. First, create a directory and then run the npx hardhat command within the directory itself:

Then, install the following dependencies using npm:

  • @tableland/sdk ⇒ The Tableland JavaScript SDK.
  • node-fetch ⇒ Tableland uses a modern fetch API — when working in Node, it is necessary to use a version of Node that supports fetch, or provide global access to node-fetch to use the SDK.
  • @openzeppelin/contracts ⇒ OpenZeppelin library for widely implemented smart contracts.
  • dotenv ⇒ Protect our keys and load them in files.
  • ⇒ IPFS pinning services for NFT images.
  • mime & files-from-path ⇒ Used when parsing files & while using, as outlined in their docs.

Also, install some prettier development dependencies and @nomiclabs/hardhat-etherscan for contract verification on Polygonscan:

Note that the .env file should resemble the following and be placed in the project’s root:

Metadata File & Image Processing

The first step we’ll walk through is dealing with local metadata files and images. If you haven’t already, clone the repo so that you can get the metadata JSON files and the assets themselves. Technically, any image or set of metadata values will work, assuming it follows the same format.

Namely, the image filenames in the images directory align with the filenames in metadata — these will actually end up being our NFT’s tokenId:

  • Image 0.jpeg ⇒ metadata file 0 (note — ensure there is no extension needed on the metadata file itself, which is a common practice with metadata + IPFS, in general)
  • Image 1.jpeg ⇒ metadata file 1
  • etc.

Let’s first get the project set up. Create a file named metadataProcessing.js in scripts and insert the following code:

These imports include NFT.Storage (for uploading/pinning the images to IPFS), various related file system libraries / helpers (for reading/writing files), and dotenv for loading any account private keys or API keys from the .env file. Additionally, the nftStorageApiKey will be used during the IPFS uploading process, which reads from this .env file.

We’ll want to create a few helpers for reading the metadata directory’s files as well as the images in images. With fileFromPath, it allows you to pass a file’s path and reads the data itself, using the mime library to help guess the filetype, then, returning the as filesystem File.

There are number of steps involved to get the data ready using prepareMetadata. These helpers will make a little more sense once that function is written (see the bottom of this section), but let’s walk through them anyways.

fileFromPath & uploadImageToIpfs

This helper will then be used by uploadImageToIpfs, which, well, uploads an image to IPFS. Note that it take both an id (name of the image file that matches the metadata file, e.g., 0) and imagesDirPath (the path to the images directory) — it returns the IPFS CID of the uploaded image.


From there, it’s time to parse the metadata JSON files. Start by passing the id (i.e., this will be 0 or 1 from the example directories) as well as the corresponding file paths to the metadata and images directories. The parseMetadataFile method will do a few things:

  • Call uploadImageToIpfs and get the corresponding image’s CID.
  • Search the metadataDirPath for a matching image in images — hence, why the naming convention is important for having a 1:1 relationship with metadata:images.
  • Parse the metadata file and save it to an object named metadataJson.
  • Use the image’s CID to overwrite the empty image field in the metadata to the NFT.Storage link (i.e., as https://${imageCid}
  • Additionally, write the image filed in the actual metadata JSON file — not just the metadataJson object
    • Note: this step isn’t required for Tableland usage, but it’s potentially helpful for tutorial / demonstration to help see what the final metadata looks like.
  • Lastly, return the metadataJson object, which consists of the fields parsed from the file in metadata as well as the overwritten image value, which is now the image’s CID at the NFT.Storage gateway.


Lastly, we’re going to prepare the metadata by using the methods above. The metadataDirPath and imagesDirPath are hardcoded here, and all of the files from these paths are processed accordingly. The main callout is that an id field is added to the object, which will later be used while processing the SQL statements. The id itself is parsed using from the metadata file’s name:

Basically, the for...of loop will iterate through all files in the directory defined as metadataFiles. It takes each metadata file, gets its name (using replace(/^\//, "")), creates a metadata object (with the IPFS CID), and inserts the new id field. Lastly, every metadata objet is stored in a finalMetadata array, which is returned by the function.

The finalMetadata object will then be used when preparing SQL statements. Hence, the only export from this file is the prepareMetadata since it leverages all of the preceding helpers.

SQL Time

Great, now we’re ready for some SQL; no more working with files or directories! Recall we’ve done the following:

  1. Read files from images and metadata directories
  2. Uploaded images to IPFS
  3. Created objects from the files in the metadata directory
  4. Pushed the objects into an array called finalMetadata

Let’s create a new file called prepareSql.js. We’ll start out by importing dotenv (for reading .env) and the prepareMetadata function:

Traditionally, metadata is often pushed to something like IPFS or stored in some centralized database as a JSON file. Tableland believes that structured data is a better solution, considering the ERC721 metadata spec itself is structured. This data is meant for tables, so let’s create two of them — one for “main” metadata fields (all of the top-level fields, except attributes), and one for the objects contained in the attributes field. Using SQL and JOINs, these two tables can be combined into a single object that is ERC721 metadata compliant.


Next, we’ll put together a function to prepare the metadata for Tableland by creating SQL INSERT statements. When this function is called, it will be passed two parameters: a mainTable and an attributesTable. Later in our deployTwoTables.js script, we’ll actually create the tables that will use these statements. For reference, this is what our schema will look like — the main table has all of our NFT tokens, and attributes is linked to this table using main_id:

Let’s walk through the flow:

  1. The function calls prepareMetadata, which returns an array of metadata objects (including the image’s CID) and saves this as the variable metadata.
  2. An array sqlInsertStatements is initialized to hold all of the SQL INSERT statements produced.
  3. The main metadata values are destructured from each value in the array returned from prepareMetadata and then used to create SQL statements:
    1. Notice the usage of single quotes (') around the strings but not the number.
  4. The attributes metadata values follow similar logic, but since there will be more than one attribute per token, it is necessary to hold these in an array called attributesTableStatements:
  5. Lastly, the statement are built into a statement object — that is, each statement will have both the main table’s statement and the attributes table statement. These are captured in an array called sqlInsertStatements, which is what is finally returned — an array of objects where each object has all of the necessary SQL INSERT statements.

Putting it all together:


When constructing the ERC721 compliant metadata, we’ll actually be using SQL to JOIN two tables. This will actually happen within the smart contract, but let’s see what the query will be:

This is the exact same style that the Tableland Rigs NFT uses. The <main_id> will be the tokenId of the minted NFT, which will be constructed in the tokenURI at the smart contract. There are a lot of cool SQL features, such as json_group_array and json_object, which help construct the proper JSON output. If we review the query, it is basically saying that from the main and attributes table, join them on the matching id and main_id, create a JSON object (main table data) with an attributes field. This field is created with a JSON array with JSON objects (the attributes).

TwoTablesNFT Smart Contract

Now, before we can wrap things up and deploy these tables, we’ll need to create our NFT contract. This is a rather basic implementation; it simply imports some OpenZeppelin contracts and helpers. Note that some of the implementations are not best practices but used for demonstration purposes (e.g., certain public state variables that should be internal).

Start by creating a TwoTablesNFT.sol file in the contracts directory and add some state variables:

Let’s review the state variables:

  • baseURIString ⇒ The base URI for our NFTs; this will be the Tableland gateway. Note that it is in the format where appended query? allows for SQL read queries (SELECT * …) and the unwrap=true&extract=true formats the response as ERC721 compliant metadata.
  • mainTable ⇒ This will be the name of the main table that holds all NFT top-level metadata, except for the attributes, which will be joined in the tokenURI method.
  • attributesTable ⇒ For all attributes that are typically an array of objects that have trait_type and value keys.
  • _tokenIdCounter & _maxTokens ⇒ A counter for NFT tokenIds and the corresponding max that can be minted.

Most of these will be set in the constructor. The totalSupply method is used by block explorers and marketplaces, and the _baseURI function simply returns the baseURIString, which is used when constructing the NFT’s metadata. Now, the interesting part!


Every NFT has a tokenURI that points to the NFT’s metadata. This response must conform to the ERC721 format (see OpenSea for more details). When we deploy our main and attributes tables, we’ll actually be using SQL in the smart contract to compose these two tables. Note that in this tutorial, both tables are being deployed to Polygon. It is possible to have cross-chain JOINs for true multi-chain composability!

Recall that from the SQL section above, we’re using SQL functions like json_group_array and json_object to compose the data with a SELECT + JOIN statement. The first half of the function is some standard requirements / boilerplate; the latter half is where things get interesting.

Check out the query variable — it is creating the SQL statement mentioned above and composing our NFT’s metadata! This is what allows NFT marketplaces to render the NFT metadata, such as the traits and images. It may be helpful to programmatically or manually URL encode the SQL statement itself, such as an online encoding tool (creating an output like SELECT%20json_object%28%27id…).

Lastly, the mint function allows users to mint a token. It simply checks a token counter and ensure the max has not been reached:

Putting everything together, we’ve created our TwoTablesNFT smart contract that will leverage Tableland for web3-native metadata.

Table Time + Polygon

Time for creating tables and deploying our NFT to Polygon! A quick recap — we:

  • Read/parsed local metadata JSON files and images, turning them into JavaScript objects
  • Used these metadata objects to create SQL INSERT statements
  • Created a smart contract that will allow users to mint an NFT, using the data defined above

The missing piece is the tables that actually hold the metadata as well as the smart contract deployment. Let’s being with the tables.

Creating Tables

We’ll first start by creating our deploy script in scripts, called deployTwoTables.js. This will basically take the default hardhat script and customize it for Tableland functionality. First, import the required packages, including @tableland/sdk:

Next, we’ll add all of our functionality in the main function, which will be called when we run the deploy script. One of the most important aspects actually will come in our hardhat.config.js, so let’s take a look at that real quick. In the root of the project, update hardhat.config.js. The primary callouts:

  • The hardhat key can be helpful when running things locally with npx hardhat node during development. We’re going to skip right to the testnet!
  • For networks, many can be added, but here, we’re only going to deploy to Polygon Mumbai. The PRIVATE_KEY is from the .env file, which is the key to the development wallet being used.
  • Optionally, we can also verify contracts on Polygonscan — this is where some optional aspects come into play, but it’s a fun exercise.

Back to deploying TwoTablesNFT and creating Tableland tables. We’ll first start by using ethers.getSigners() to retrieve the accounts from our hardhat.config.js when deploying to Polygon. We’ll also specify a couple of Tableland specifics:

  • In Tableland’s connect method, there is a signer and chain passed, which uses these to connect to the Tableland testnet
  • The variables mainSchema, attributesSchema, mainPrefix, and attributesPrefix are all declared. These are used when minting a Tableland TABLE from the TablelandTables registry contract, and recall the schemas correspond to all of our previous write statements:

Then, we’ll call create and pass the mainSchema and an object { prefix: mainPrefix } to specify the table’s human readable name. Also, be sure to use the receipt method to validate if the transaction was successfully created or not. We’ll also repeat this exact process for the attributes table.

When using create, there returned values include a couple of useful fields in which we’ll save for later usage:

  • name ⇒ The name of the table, which is multi-chain unique identifier in the format {prefix}_{chainId}_{tableId}; this is essential to save. Without it, you won’t know what table to insert data into nor the smart contract won’t know either.
  • txnHash ⇒ Upon the transaction being confirmed, the value will be returned; combine it with receipt to verify everything worked as expected.

Woo! We finally have our tables! Now, it’s time to insert some data and finally deploy the contracts.

Inserting Metadata

Recall that our method prepareSqlForTwoTables allowed use to pass a main and attributes table name. These were created in the steps above and returned from the create method. Each object in the array sqlInsertStatements represents a single NFT — each object has both the main and attributes table INSERT statements. Since our previous work helped prepare these SQL statements, the steps are rather straightforward:

  • The sqlInsertStatements is iterated over and destructured into main and attributes variables, which are the SQL statement for each table.
  • Using the write method, the main table is written to, and thereafter in another loop, the attributes table is written to (twice, since our metadata had two trait_types). We could get more efficient here on write queries and JavaScript logic, but let’s keep it simple.
  • The write method will return the on-chain transaction’s hash — this can be used with receipt to make sure everything worked as expected.

And now the tables have some life! The metadata has been written to the tables as a pre-deploy action. Note that it is possible to perform SQL INSERTs as part of the smart contract itself. For example, a common pattern is to create a table and insert into it in the constructor function of a smart contract. Here, we’re doing things in JavaScript instead.

Deploying to Polygon

Time for the main event — getting our TwoTablesNFT deployed on Polygon to then leverage Tableland for composable metadata! We’ll first start off by defining the base URI to be used by the smart contract and the tokenURI. This will be the Tableland gateway with the appended query (which enables SQL statements to be appended) and unwrap=true&extract=true (formatting as ERC721 compliant):

Next, we’ll deploy the smart contract using an ethers method called getContractFactory, which simply takes the name of the smart contract. Calling the deploy method the returned valued from the factory (variable declared as TwoTablesNFT) will do just that — deploy the smart contract. The smart contract itself took a few parameters in the constructor — the parameters passed to the deploy function should match with what was defined in the smart contract.

Boom! Table time. Double table time, to be exact. We will have some tables on Polygon, shortly! One follow up step is provided for sake of demonstration. This is best served in a testing scenario where you can write tests in the tests directory and run npx hardhat test to run them. Here, we’re going to do a pseudo-test for fun — minting a token in the deploy script itself.

We can call the smart contract directly, such as seeing what the public getter on baseURIString is…just for fun. Afterward, we can also mint a token by calling mint(). The subsequent logic after that function call also reads some events from the blockchain, including getting the minted tokenId and using that to check out the tokenURI's return value…which points to Tableland!

Verifying Our Contract

Okay, final “optional” step before we mint our tables and deploy the TwoTablesNFT contract. Ensure you’ve signed up for a Polygonscan account, created an API key, and saved it to .env as POLYGONSCAN_API_KEY. Since we’ve already installed @nomiclabs/hardhat-etherscan, we just need to write the simple script.

This verification logic has been included in deployTwoTables.js, but it may be useful to do this as a separate step. As such, verifyTwoTables.js is provided for those who wish to separate roles of responsibility accordingly. The main consideration is to — just as the deploy method required — pass the constructor arguments. This includes the Tableland gateway and those previously saved variable for table names in the format {prefix}_{chainId}_{tableId}.

Actually, Deploying

Whew…we’re finally ready to put everything together. All that’s left is to run the hardhat script — the main callout here is that we’re passing “polygon-mumbai” as the network, which we previously defined in the hardhat.config.js file:

And BOOM. Tables have been deployed on Polygon:

If you followed along and performed all of the optional actions and kept the associated logging, the output in the console should have looked something like this:


It goes without saying that testing should be part of any deployment process. It’s especially important with Tableland where SQL is a new concept in terms of web3-native functionality (of course, SQL has been around for decades). As such, doing dry runs and sanity checks is crucial and deserves the proper attention. Some common issues include properly escaping single/double quotes, ensuring all text is surrounded by single quotes, and following general SQL syntax guidelines (for anyone that’s a little newer to SQL).

In terms of testing smart contracts, hardhat has built in features to do this with chai. A very basic test script has been provided, which does a couple of simple checks, like minting a token, transferring it, and validating if the Tableland tokenURI is in the correct format. Basically, set up some top-level variables in describe (so that they can be re-used in subsequent tests). Sample values have been provided, and upon running npx hardhat test, this test (as well as the others provided) will all run…and pass!

Recap & Review

Let’s review everything we accomplished:

  • Read metadata JSON files from a local directory, pushed images to IPFS, and then created JS objects as a combination of the two.
  • Prepared a bunch of SQL insert statements — for both a main and attributes table.
  • Created tables for both a main and attributes, which is a much more optimal way to do metadata and enables endless use cases for mutability thereafter (if desired).
  • Inserted the prepared metadata as part of the same deploy script as the creates.
  • Deployed an NFT smart contract to Polygon Mumbai and verified the on Polygonscan using a script.
  • Lastly, since Polygon’s testnet is supported by OpenSea, it’s possible to view everything from this tutorial as you would any other NFT collection (well, any other testnet collection).

View it on OpenSea

And lastly, the collection is “live” on OpenSea (testnet)! You can even set up custom banners and verification as desired, and you can view the collection here. Here’s what it looks like: