Let’s see how we can securely lazy mint ERC721 tokens on Polygon with Arweave as Metadata Storage

Technical Stack

This is going to be mostly a technical story with code samples and logic behind lazy minting. A lot of explanations exists in the code blocks within comment lines. This is not going to be a step by step story. You can find the repositories at the end to check out in depth and implement to your needs in a more modular way.

  • Blockchain
    - Polygon (as chain)
    - Arweave (as metadata storage)
    - Bundlr (as tx processor for Arweave)
    - Hardhat (as framework)
  • API
    - AWS SAM (as framework)
    - AWS S3 (as temporary metadata storage for lazy minting)
    - AWS Secrets Manager (to store wallet keyfile and metadata secret)
    - AWS DynamoDB (to store lazy data)
    - Node/Typescript (as runtime)
  • Frontend
    - Vite/Preact (for tooling)
    - Ethers (as library for contract interactions)
    - Alchemy (to fetch minted NFTs)
    - Tailwind (for UI)

What is Lazy Minting

Lazy minting is “having to mint NFT at the time of purchase” with one sentence. For NFTs to be available in Marketplaces like Opensea they have to be minted first but to mint it I have to pay fees. If I have thousands of NFTs in my collection minting all of them will be very costly for me. It should only be minted at the time of purchase and not by me. I want buyer to pay for fee because if I pay for thousands of NFTs minting fee it will be a lot and costly.

Some marketplaces like Rarible and Opensea already support this but the thing is if you have a NFT project and looking forward to mint thousands of NFTs and integrate with the marketplaces, unfortunately Opensea and a lot of other marketplaces does not have an API to send lazy mint data (Rarible have).

What we are going to do is: we are going to create a very basic NFT Marketplace website of our own and implement that ourselves.


I will not go over every part of the code because it’s separated into 3 different parts and has lots of code. The important part is the logic behind lazy mint, not the coding, I will only go over important parts.

Basic Flow


Let’s start with the Serverless API with AWS SAM. You can use sam init and use Hello World template with nodejs18.x and Typescript.
Next, edit the template.yaml and define 3 different AWS::Serverless::Function . One for fetching all the NFTs, one for premint (uploading metadata to Arweave and creating id for validation) and one for deleting the NFT from DynamoDB after successful mint.
We also need DynamoDB to store lazy mint data, S3 Bucket to store metadata images so later we can upload those to Arweave and also a AWS::IAM::Role to configure policies for DynamoDB, S3 and Secrets Manager.

If you created your SAM with sam init you already should have a folder, create a get-nfts.ts file inside:

import { APIGatewayProxyResult } from 'aws-lambda';
import { ScanCommand } from "@aws-sdk/client-dynamodb";
import { ALLOW_ORIGIN, NFT_TABLE_NAME, NULL_ADDRESS } from './config';
import { handleError } from './helpers';
import { dynamoDBClient } from './aws-services/dynamodb';

export const lambdaHandler = async (): Promise<APIGatewayProxyResult> => {
    try {
        const { Items } = await dynamoDBClient.send(new ScanCommand({ TableName: NFT_TABLE_NAME }));

        const data = Items?.map((item) => ({
            id: item.id.S,
            name: item.name.S,
            description: item.description.S,
            image: item.image.S,
            ...(item.attributes ? { attributes: JSON.parse(item.attributes.S) } : {}),
            ownedBy: NULL_ADDRESS

        return {
            statusCode: 200,
            headers: {
                "Access-Control-Allow-Headers": "Content-Type",
                "Access-Control-Allow-Origin": ALLOW_ORIGIN,
                "Access-Control-Allow-Methods": "GET,OPTIONS",
            body: JSON.stringify(data)
    } catch (err: any) {
        return handleError(err);

It’s very basic, we’re just scanning the DynamoDB table and return the data after formatting with some CORS headers. I will not go over delete.ts since it’s as easy as get-nfts.ts .

Now create a new file named premint.ts inside, this is one of the most important parts:

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { ALLOW_ORIGIN, BUCKET, NFT_TABLE_NAME } from './config';
import { z } from 'zod';
import { createArweaveUrl, createId, handleError } from './helpers';
import Bundlr from "@bundlr-network/client";
import { Readable } from 'stream';
import { getSecret } from './aws-services/secret-manager';
import { s3Client } from './aws-services/s3';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { dynamoDBClient } from './aws-services/dynamodb';
import { GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';

export const Validator = z.object({
    id: z.string()

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        if (!event.body) throw Error();
        const { id } = await Validator.parseAsync(JSON.parse(event.body));

         // find the corresponding nft in DynamoDB
        const { Item } = await dynamoDBClient.send(new GetItemCommand({
            TableName: NFT_TABLE_NAME,
            Key: {
                id: { S: id }
        const nft = {
            id: Item.id.S,
            name: Item.name.S,
            description: Item.description.S,
            image: Item.image.S,
            ...(Item.attributes ? { attributes: JSON.parse(Item.attributes.S) } : {}),
            ...(Item.tokenURI ? { tokenURI: Item.tokenURI.S } : {}),

        if (!nft) throw Error(`${id} is not found.`); // handle and return 404 if u care

        let response;

        // if there is a tokenURI already it means we've uploaded to arweave, just return it
        if (nft.tokenURI) {
            response = {
                id: await createId(nft.tokenURI, nft.name),
                url: nft.tokenURI,
                name: nft.name
        } else {
            // get the jwk needed to sign bundlr transactions (it's basically a keyfile of arweave)
            const { jwk: jwkString } = await getSecret('jwk');
            const bundlr = new Bundlr('http://node2.bundlr.network', 'arweave', JSON.parse(jwkString));

            // upload the image in S3
            const { pathname } = new URL(nft.image);
            const [, ...key] = pathname.split('/');
            const { Body, ContentType, ContentLength } = await s3Client.send(new GetObjectCommand({ Bucket: BUCKET, Key: key.join('/') }));

            const { id: imageId } = await bundlr.upload(Body as Readable, {
                tags: [{ name: 'Content-Type', value: ContentType }]
            nft.image = createArweaveUrl(imageId); // https://arweave.net/:imageId

            // upload metadata json
            const { id: _, ...nftMetadata } = nft;
            const metadata = JSON.stringify(nftMetadata);

            const { id: metadataId } = await bundlr.upload(metadata, {
                tags: [{ name: 'Content-Type', value: 'application/json' }]
            nft.tokenURI = createArweaveUrl(metadataId); // https://arweave.net/:metadataId

            // update the nft data so if user abandon transaction after files uploaded, reuse them and prevent reupload
            await dynamoDBClient.send(new UpdateItemCommand({
                TableName: NFT_TABLE_NAME,
                Key: {
                    id: { S: id }
                UpdateExpression: 'set image = :image, tokenURI = :tokenURI',
                ExpressionAttributeValues: {
                    ':image': { S: nft.image },
                    ':tokenURI': { S: nft.tokenURI }

            response = {
                // create the hash to validate if this is a valid NFT belongs to this project, add description too to the hash
                // also edit the Contract if description is added. Unnecessary for test app
                id: await createId(nft.tokenURI, nft.name),
                url: nft.tokenURI,
                name: nft.name

            // // fund the bundlr with the price used for files so it doesn't run out
            const price = await bundlr.getPrice(ContentLength + metadata.length);
            await bundlr.fund(price);

        return {
            statusCode: 200,
            headers: {
                "Access-Control-Allow-Headers": "Content-Type",
                "Access-Control-Allow-Origin": ALLOW_ORIGIN,
                "Access-Control-Allow-Methods": "POST,OPTIONS",
            body: JSON.stringify(response)
    } catch (err: any) {
        return handleError(err);

So every part is explained in comments. One important part here is security. What prevents user from minting whatever data they want? createId(nft.tokenURI, nft.name) this creates a keccak256 hash with tokenURI and name but also a secret only server and contract knows.

import createKeccakHash from 'keccak';
import { getSecret } from "./aws-services/secret-manager";

export const createId = async (...args: string[]) => {
    const { METADATA_SECRET } = await getSecret('metadata-secret');

    const hash = createKeccakHash('keccak256').update(args.join('') + METADATA_SECRET).digest('hex');
    return '0x' + hash;

We’ll also see that in the contract implementation. This way, we check for signatures so users can only mint the NFTs that we allow. Because it’s signed with a secret that only server and contract knows. If you try to change the tokenURI for example since users don’t know the secret it’s id will not be the same as the one in the database.

API Deployment

Generate a Makefile in the folder:

.PHONY: start-api deploy-dev test

 sam build && sam local start-api

 sam build && sam deploy -g

 npm run test

Just run make deploy-dev and sam-cli will take you through the deployment process. That’s it, API is done.


Now let’s create our Solidity Contract with using Openzeppelin. If you have not create a hardhat repository and configure it. Then create a file in contracts folder named Spacer.sol :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract Spacer is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;

    string private nftMetadataSecret;

    constructor(string memory _nftMetadataSecret) ERC721("Spacer", "SPR") {
        nftMetadataSecret = _nftMetadataSecret;

    function mint(string memory uri, string memory name, bytes32 id) public payable {
        require(validateId(uri, name, id), "Invalid Hash");
        require(msg.value == 0.01 ether, "0.01 ether is required");
        uint256 tokenId = _tokenIdCounter.current();
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, uri);

    function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {

    function tokenURI(uint256 tokenId)
        override(ERC721, ERC721URIStorage)
        returns (string memory)
        return super.tokenURI(tokenId);

    function validateId(string memory uri, string memory name, bytes32 id) private view returns(bool) {
        return keccak256(abi.encodePacked(uri, name, nftMetadataSecret)) == id;

As you can see in the contract, we have this metadata secret in the constructor function and stored as private variable. So we can use it in validateId . We hash the tokenURI, name and nftMetadataSecret just like in the API and compare it to the id we sent from the Frontend (which gets the data from the API). That means if user changes the tokenURI or the name, the generated id will be different than the id that is returned from the API.

Blockchain Deployment

First edit the configuration and define a task in your hardhat.config.ts :

import { HardhatUserConfig, task } from "hardhat/config";
import * as dotenv from 'dotenv';
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: "0.8.18",
  networks: {
    mumbai: {
      url: MUMBAI_RPC_URL,
      accounts: [`0x${PRIVATE_KEY}`]

task('deploy', 'Deploy a contract', async (_, hre) => {
  const Spacer = await hre.ethers.getContractFactory('Spacer');
  const spacer = await Spacer.deploy(NFT_METADATA_SECRET);
  await spacer.deployed();


export default config;

We have the same kind of Makefile for the Blockchain part as well:

.PHONY: deploy-dev test

 npx hardhat deploy --network mumbai

 npx hardhat test

 rm -rf artifacts cache typechain-types

Just run make deploy-dev and it will deploy your contract to Polygon Mumbai network and log out the contract address. That’s it, Blockchain is done as well.


Now first of all create a Vite project with React or Preact, your choice. I’ll go with Preact. You can play with your Frontend as you like. I’ll only go over the API and Contract relations. First of all we need a Metamask context to be able to use the contract and connect to Metamask.

import { useContext, useState, useEffect, useCallback } from "preact/hooks";
import { ethers } from "ethers";
import { ComponentChildren, createContext } from "preact";
import { ABI, CONTRACT_ADDRESS } from "@/config";

const MetamaskContext = createContext<{
    isMetamaskInstalled: boolean,
    isMetamaskLoading: boolean,
    isMetamaskConnected: boolean,
    accounts: ethers.JsonRpcSigner[],
    provider?: ethers.BrowserProvider,
    contract?: ethers.Contract
    connectToMetamask: () => Promise<void>
    isMetamaskInstalled: false,
    isMetamaskLoading: false,
    isMetamaskConnected: false,
    accounts: [] as ethers.JsonRpcSigner[],
    provider: undefined,
    contract: undefined,
    connectToMetamask: async () => { }

export function useMetamask() {
    return useContext(MetamaskContext);

export function MetamaskProvider({ children }: { children: ComponentChildren }) {
    const [isMetamaskLoading, setIsMetamaskLoading] = useState(false);
    const [isMetamaskInstalled, setIsMetamaskInstalled] = useState(false);
    const [isMetamaskConnected, setIsMetamaskConnected] = useState(false);
    const [accounts, setAccounts] = useState<ethers.JsonRpcSigner[]>([]);
    const [provider, setProvider] = useState<ethers.BrowserProvider>();
    const [contract, setContract] = useState<ethers.Contract>();

    useEffect(() => {
        // check if metamask already connected or not by listing accounts and set states
        !async function () {
            if (window.ethereum) {
                const provider = new ethers.BrowserProvider(window.ethereum);
                const accounts = await provider.listAccounts();
                setIsMetamaskConnected(accounts.length > 0);
    }, []);

    // connect to metamask by calling eth_requestAccounts method, it will force open metamask popup and asks user to connect
    // difference between eth_requestAccounts and provider.listAccounts is provider only list accounts that are connected
    // eth_requestAccounts will force user to connect their wallet to get the accounts
    async function connectToMetamask() {
        if (window.ethereum) {
            try {
                const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
                setAccounts(accounts.map((account: string) => ({ address: account })));
            } catch (error) {
        } else {
            console.error("Metamask not detected");

    // set the contract with it's address from .env and ABI in constants if we have the wallet connected
    useEffect(() => {
        !async function () {
            if (!provider) return;

            const accounts = await provider.listAccounts();
            if (!accounts.length) return;

            const signer = await provider.getSigner();
            setContract(new ethers.Contract(CONTRACT_ADDRESS, ABI, signer));
    }, [provider, accounts])

    const value = {

    return (
        <MetamaskContext.Provider value={value}>

We’ve created our context to access Blockchain related things. Now let’s create our page to list and mint NFTs (you can separate effects to different services that uses query hooks to be more modular if you like, i don’t care at the moment):

import { useEffect, useState } from 'preact/hooks';
import { NFT, TxStatus } from '@/types';
import { useMetamask } from '@/context/metamask';
import { request } from '@/helpers';
import { Card } from '@/components/nfts/card';
import { alchemy } from '@/services/nfts-service';
import { ethers } from 'ethers';

export function NFTs() {
    const [lazyNFTs, setLazyNFTs] = useState<NFT[]>([]);
    const [NFTs, setNFTs] = useState<NFT[]>([]);
    const [txStatus, setTxStatus] = useState<Record<string, TxStatus>>({});
    const { contract, accounts } = useMetamask();

    // get the lazy nfts from our api
    useEffect(() => {
        const abortController = new AbortController();

        !async function () {
            const result = await request.send<NFT[]>('/', { signal: abortController.signal });

        return () => abortController.abort();
    }, []);

    // get the minted nfts from Alchemy and mark my nfts as owned by me so i can show a badge or by someone else
    // implement someone else logic by showing the address of owner as well if you like
    useEffect(() => {
        !async function () {
            const contractNfts = await alchemy.nft.getNftsForContract(CONTRACT_ADDRESS);

            const nfts = contractNfts.nfts.map(nft => ({
                id: nft.tokenId,
                tokenURI: nft.tokenUri?.raw,
                ownedBy: SOMEONE_ELSE
            })) as NFT[];

            if (accounts[0]?.address) {
                const { ownedNfts } = await alchemy.nft.getNftsForOwner(accounts[0].address, {
                    contractAddresses: [CONTRACT_ADDRESS]

                for (const ownedNft of ownedNfts) {
                    const idx = nfts.findIndex(nft => nft.id === ownedNft.tokenId);
                    if (idx === -1) return;

                    nfts[idx].ownedBy = accounts[0].address;

    }, [accounts])

    const handleMint = async (id: string) => {
        if (!contract) {
            alert('Please connect your wallet first.');

        // set status of the transaction as pending to show user
        setTxStatus({ ...txStatus, [id]: TxStatus.Pending });
        try {
            // call premint to get Arweave tokenURI and id (hash that we use to validate)
            const result = await request.send<{ url: string, name: string, id: string }>('/premint', {
                method: 'POST',
                body: JSON.stringify({ id }),

            // call the mint function in contract with url, name, id
            const tx = await contract.mint(result.url, result.name, result.id, { from: accounts[0].address, value: ethers.parseEther('0.01') });
            const r = await tx.wait();

            // if it's successfully minted then remove it from our database because it's already in the chain now
            // and will be fetched above with the Alchemy
            if (r.status === 1) {
                // you can lock this before tx starts to prevent remints after page refresh, i don't care
                await request.send(`/${id}`, { method: 'DELETE' });

            // set the status to success or fail depending on the receipt status
                [id]: r.status === 1 ? TxStatus.Success : TxStatus.Fail
        } catch (err: any) {
            if (err.reason === 'rejected') {
                setTxStatus({ ...txStatus, [id]: TxStatus.None });

    const getStatus = (item: NFT) => {
        if (item.ownedBy === NULL_ADDRESS) { // if null adress it's not minted yet
            return TxStatus.None;
        } else if (item.ownedBy === accounts[0]?.address) { // if belongs to connected metamask's address
            return TxStatus.Success;
        } else if (item.ownedBy === SOMEONE_ELSE) { // if belongs to someone else
            return TxStatus.Owned;

        return txStatus[item.id]; // get the status from the state

    // merge the lazy nfts and minted nfts together and render
    const data = [...lazyNFTs, ...NFTs];

    return (
        <div className="container mx-auto mt-8">
                data.length ? (
                    <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
                            data.map(item => <Card
                                handleMint={() => handleMint(item.id)}
                ) : <>Loading...</>

Frontend Deployment

Just start it with npm run dev and you have it. See the website: https://nft-marketplace-frontend.deno.dev/


Repositories are public below, you can see API, Blockchain and Frontend codes within: