How to Organize User-Generated Content On IPFS With Groups

Using Pinata, Privy, and Alchemy

Justin Hunter
Pinata
23 min read3 days ago

--

One of the challenges of building apps is organizing data in an efficient and logical way. This has historically been even more difficult with IPFS. IPFS, as an immutable and peer-to-peer file storage protocol, leaves data organization to developers to handle. Fortunately, Pinata’s Groups feature makes this much simpler.

To explore Groups, we’re going to build a simple app that allows uploading of user-generated content. There are many practical applications of this — a decentralized alternative to Google Docs like Fileverse recently built using Pinata, a social media app like Supercast which leverages Pinata for images and video, and so much more — so this tutorial can be extended to nearly limitless use cases.

Our app is not going to be anything fancy. We’re going to an app that lets users back up any NFTs found in their wallet. IPFS is as permanent as you make it, so such a tool is actually valuable for people who want more control over the media associated with their NFTs.

Let’s get started.

Getting Started

To build this app, we need a Pinata account. You can sign up for a free account here. Once you’ve signed up for your account, you’ll need an API key. Log in and navigate to the API Keys page. From there, create a new key with admin permissions. Once you’ve generated the key, you’ll see a key, a secret, and a JWT. We only need the JWT. Save that somewhere until we’re ready for it.

We’re also going to be using Privy for our wallet provider, so you’ll need to sign up for a free Privy account here. Follow the steps to configure your new app. We’ll make use of Privy’s JavaScript SDK soon.

Finally, you’ll need a free Alchemy account. We’ll be using Alchemy’s NFT API to fetch NFT data for users that connect their wallet. You can sign up here.

Make sure you have a recent version of Node.js installed and a good code editor. Outside of that, we’re ready to code.

Building the app

We’ll be using Next.js for this project so that we can make use of the React frontend and the serverless backend. Open up your terminal program and navigate to the folder where you keep your developer projects. In that folder, run the following command:

npx create-next-app nft-backup

You can accept the defaults (or edit them as you wish, but this project will use the defaults) during the installation process. Once everything is installed, navigate into your project folder with this command:

cd nft-backup

Now, we can open the project in our favorite code editor. Let’s get the foundation set up before we integrate Pinata and Privy. We need to have a button to connect a wallet, and we need a view that will show NFTs in a user’s wallet and NFTs that have been backed up.

Let’s start with the app’s header/nav bar. Inside your project, find the src folder. Add a new folder within it called components. Inside the components folder, create a file called Header.tsx. In that file, add the following:

import Image from "next/image";

const Header = () => {
return (
<div className="flex flex-row items-center justify-between">
<div className="flex items-center">
<Image src="/reverse_pinnie.png" alt="Reversed Pinnie" width={60} height={60} />
<p className="font-bold text-xl ml-2">NFT Backup</p>
</div>
<button className="rounded-full px-8 py-2 bg-indigo-400 hover:bg-indigo-900 text-black font-md">Connect wallet</button>
</div>
)
}

export default Header

This is our top nav bar for the app. It has an image and app name along with a Connect wallet button. We’re using Tailwind for the CSS layout. The image you use for your logo can be anything, but you’ll want to be sure to put it in the public folder in your project. This is what the app looks like now, if you run it:

Nothing is functional yet, but we’re building the foundation. Let’s continue by building a tab switcher. This will allow us to switch between the NFT view (which shows everything in a user’s wallet) and the backup view (which shows everything that’s been backed up). Create another new component file in the components folder and call it view_switcher.tsx. In that file, add the following:

import React from 'react'

type ViewSwitcherProps = {
setNftView: (nftView: boolean) => void ;
nftView: boolean;
}

const ViewSwitcher = ({ setNftView, nftView } : ViewSwitcherProps) => {
return (
<div className="flex items-center mt-16">
<button onClick={() => setNftView(true)} className={`mr-4 ${nftView ? "underline" : ""}`}>My NFTs</button>
<button onClick={() => setNftView(false)} className={`${!nftView ? "underline" : ""}`}>My backups</button>
</div>
)
}

export default ViewSwitcher

This component is very simple. It creates two buttons, horizontally aligned, and allows for switching between them. We pass down the setNftView function and the nftView variable.

Next, we should create a wrapper component that will act as our entry point. In the components folder, add a file called main.tsx and add the following:

"use client"
import Header from "@/components/header";
import ViewSwitcher from "@/components/view_switcher";
import { useState } from "react";

export type NFT = {
name: string;
description: string;
tokenUri: string;
}

export default function Main() {
const [nftView, setNftView] = useState(true)

return (
<div className="max-w-[1280px] px-8 py-4 m-auto">
<Header />
<ViewSwitcher nftView={nftView} setNftView={setNftView} />
</div>
);
}

Let’s link this up by opening up your src/page.tsx file and updating it to look like this:

"use client"
import Main from '@/components/main';

export default function Home() {
return (
<Main />
);
}

Not much there, but it connects your entry component to the home page of the app. Now, if you run your app, you should be able to switch between tabs. It doesn’t do anything yet, but the foundation is coming along!

Let’s move on and build the foundation for what will live in the two tabs. We’ll need an empty state for each of these tabs, so we can start there. First, create an nfts.tsx file in your components folder. Inside that file, add the following:

import { useState } from "react"

export type NFT = {
name: string;
description: string;
tokenUri: string;
}

const NFTs = () => {
const [nfts, setNfts] = useState([])
return (
<div>
{nfts.length > 0 ?
<div></div> :
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 576 512"><path d="M160 80H512c8.8 0 16 7.2 16 16V320c0 8.8-7.2 16-16 16H490.8L388.1 178.9c-4.4-6.8-12-10.9-20.1-10.9s-15.7 4.1-20.1 10.9l-52.2 79.8-12.4-16.9c-4.5-6.2-11.7-9.8-19.4-9.8s-14.8 3.6-19.4 9.8L175.6 336H160c-8.8 0-16-7.2-16-16V96c0-8.8 7.2-16 16-16zM96 96V320c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H160c-35.3 0-64 28.7-64 64zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120V344c0 75.1 60.9 136 136 136H456c13.3 0 24-10.7 24-24s-10.7-24-24-24H136c-48.6 0-88-39.4-88-88V120zm208 24a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"/></svg>
<h1 className="mt-6 text-xl">No NFTs</h1>
</div>
}
</div>
)
}

export default NFTs

This component renders the empty state. We’ll need to add functionality later to detect if a user’s wallet is connected so that we can also prompt connecting or importing NFTs. When we import NFTs, we’ll set the nfts state variable. We will also need to update this later to include a back up button.

Now, let’s create a file called backups.tsx in the components folder. Inside that file, add the following:

import React, { useState } from 'react'

const Backups = () => {
const [backups, setBackups] = useState([])
return (
<div>
{backups.length > 0 ?
<div></div> :
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 448 512"><path d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/></svg>
<h1 className="mt-6 text-xl">No backups yet</h1>
</div>
}
</div>
)
}

export default Backups

This file is currently just the empty state, but you might notice that we aren’t passing in backups as a prop in the component. That’s because we will eventually load the backups only when this page mounts, so we’ll keep it confined to this file. The benefit of doing that is it reduces unnecessary network requests and keeps the code organized. But, if you wanted your backups page to feel instant when loading, you might want to load backups in your src/page.tsx file behind the scenes and pass the backups as a prop.

Let’s update the main.tsx file now to look like this:

"use client"
import Backups from "@/components/backups";
import Header from "@/components/header";
import NFTs from "@/components/nfts";
import ViewSwitcher from "@/components/view_switcher";
import { useState } from "react";

export default function Main() {
const [nftView, setNftView] = useState(true)

return (
<div className="max-w-[1280px] px-8 py-4 m-auto">
<Header />
<ViewSwitcher nftView={nftView} setNftView={setNftView} />
{
nftView ?
<NFTs /> :
<Backups />
}
</div>
);
}

Ok, this is pretty much as far as we can take things without connecting a wallet. Let’s do that.

Wallet Connection & NFT Import

As mentioned previously, we’ll be using Privy for our wallet provider. Why? Because it’s dead simple and their UX is unmatched. Let’s start by installing the Privy SDK. In your terminal, run this command:

npm install @privy-io/react-auth@latest

Next, you’ll need to grab your Privy app ID. You’ll need it for this next bit. Open up the src/page.tsx file and update it all to look like this:

"use client"
import Main from '@/components/main';
import { PrivyProvider } from '@privy-io/react-auth';

export default function Home() {
return (
<PrivyProvider
appId="your app id"
config={{
appearance: {
theme: 'light',
accentColor: '#676FFF',
logo: 'your logo url',
},
embeddedWallets: {
createOnLogin: 'users-without-wallets',
},
}}
>
<Main />
</PrivyProvider>
);
}

You’ll want to add your Privy app ID and customize the appearance however you’d like. This provider will now allow you to use Privy hooks throughout your code. Let’s test it out with our connect button that we have in our header. We should make the login/disconnect button a single component because we’ll re-use it in another view.

So, in your components folder, add a file called conntect_button.tsx and add the following:

import React from 'react'
import {usePrivy} from '@privy-io/react-auth';

const ConnectButton = () => {
const {ready, authenticated, login, logout} = usePrivy();

return (
<button className="rounded-full px-8 py-2 bg-indigo-400 hover:bg-indigo-900 text-black font-md" onClick={authenticated ? logout : login}>
{authenticated ? "Log out" : "Log in"}
</button>
)
}

export default ConnectButton

Now, let’s update our header.tsx file to look like this and use the ConnectButton component:

import Image from "next/image";
import ConnectButton from "./connect_button";

const Header = () => {
return (
<div className="flex flex-row items-center justify-between">
<div className="flex items-center">
<Image src="/reverse_pinnie.png" alt="Reversed Pinnie" width={60} height={60} />
<p className="font-bold text-xl ml-2">NFT Backup</p>
</div>
<ConnectButton />
</div>
)
}

export default Header

If you run your app and open it in your browser, you can now connect your wallet (or create a new wallet with an email address).

This means we know the users wallet address and can start looking up NFTs for that user. Let’s update the empty state for the NFTs component before we actually write code to fetch NFTs. Open the nfts.tsx file and update it to look like this:

import { usePrivy } from '@privy-io/react-auth'
import React, { useState } from 'react'
import ConnectButton from './connect_button';

export type NFT = {
name: string;
description: string;
tokenUri: string;
}

const NFTs = () => {
const [nfts, setNfts] = useState([])
const { authenticated } = usePrivy()
return (
<div>
{nfts.length > 0 ?
<div></div> :
authenticated ?
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 576 512"><path d="M160 80H512c8.8 0 16 7.2 16 16V320c0 8.8-7.2 16-16 16H490.8L388.1 178.9c-4.4-6.8-12-10.9-20.1-10.9s-15.7 4.1-20.1 10.9l-52.2 79.8-12.4-16.9c-4.5-6.2-11.7-9.8-19.4-9.8s-14.8 3.6-19.4 9.8L175.6 336H160c-8.8 0-16-7.2-16-16V96c0-8.8 7.2-16 16-16zM96 96V320c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H160c-35.3 0-64 28.7-64 64zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120V344c0 75.1 60.9 136 136 136H456c13.3 0 24-10.7 24-24s-10.7-24-24-24H136c-48.6 0-88-39.4-88-88V120zm208 24a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z" /></svg>
<h1 className="mt-6 text-xl mb-6">No NFTs</h1>
<button className="rounded-full px-8 py-2 bg-indigo-400 hover:bg-indigo-900 text-black font-md">Import NFTs</button>
</div> :
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 576 512"><path d="M160 80H512c8.8 0 16 7.2 16 16V320c0 8.8-7.2 16-16 16H490.8L388.1 178.9c-4.4-6.8-12-10.9-20.1-10.9s-15.7 4.1-20.1 10.9l-52.2 79.8-12.4-16.9c-4.5-6.2-11.7-9.8-19.4-9.8s-14.8 3.6-19.4 9.8L175.6 336H160c-8.8 0-16-7.2-16-16V96c0-8.8 7.2-16 16-16zM96 96V320c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H160c-35.3 0-64 28.7-64 64zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120V344c0 75.1 60.9 136 136 136H456c13.3 0 24-10.7 24-24s-10.7-24-24-24H136c-48.6 0-88-39.4-88-88V120zm208 24a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z" /></svg>
<h1 className="mt-6 text-xl mb-6">No NFTs</h1>
<ConnectButton />
</div>
}
</div>
)
}

export default NFTs

The changes here are just logical. If the user is logged in but we don’t have any NFTs yet, we show an import NFTs button. We use Privy’s usePrivy hook to check if the user is authenticated. If the user is logged out, we reuse the ConnectButton to let the user log in.

Now, we’re all set to import NFTs. For that, we’re going to use Alchemy. Let’s start by installing their SDK. Run the following in your terminal:

npm install alchemy-sdk

Now, let’s update our nfts.tsx file to handle the import function. First, import Alchemy at the top of your file and configure the Alchemy settings.

import { Network, Alchemy } from "alchemy-sdk";

const alchemySettings = {
apiKey: "Your Alchemy key",
network: Network.ETH_MAINNET,
};

const alchemy = new Alchemy(alchemySettings);

Here, you’ll want to set the network as whatever network you care about. I’m sticking with Ethereum mainnet for the sake of this tutorial. Make sure you also grab your API key from Alchemy and provide it in the settings config object.

Once you have this set up, add a new state variable like this:

const [importing, setImporting] = useState(false)

Then, you can create an import function below the hooks in your component like this:

const importNFTs = async () => {
const address = user?.wallet?.address;
if(!address) {
console.log("No wallet address")
return;
}
setImporting(true)
try {
const nftsForOwner: any = await alchemy.nft.getNftsForOwner(address);
let nftsToSet: NFT[] = []
for(const n of nftsForOwner.ownedNfts) {
try {
nftsToSet.push({
name: n.raw.metadata.name,
description: n.raw.metadata.description,
imageUrl: n.raw.metadata.image_url
})
setNfts(nftsToSet)
setImporting(false)
} catch (error) {
console.log(error)
throw error;
}
}
} catch (error) {
console.log(error)
setImporting(false)
}
}

This function is using Alchemy’s NFT API to grab all the NFTs owned by the user’s connected wallet. Then we are setting those properties we need for rendering and updating our nfts variable. We set the importing variable so we can show the status in the UI.

Go find the “Import NFTs” button in your code and update it to look like this:

<button onClick={importNFTs} className="rounded-full px-8 py-2 bg-indigo-400 hover:bg-indigo-900 text-black font-md" disabled={importing ? true : false}>{importing ? "Importing..." : "Import NFTs"}</button>

Now, if you try this, things should work but you won’t see any NFTs in your UI. Let’s fix that. This is going to be quite the change, so I’ll share the entire updated file and we’ll talk through it. Here’s the updated nfts.tsx file:

import { usePrivy } from '@privy-io/react-auth'
import React, { useState } from 'react'
import ConnectButton from './connect_button';
import { Network, Alchemy } from "alchemy-sdk";

const alchemySettings = {
apiKey: "A5Bt1Nj9lPhZCvq4D5yk4RQQUTeZJzKu",
network: Network.ETH_MAINNET,
};

const alchemy = new Alchemy(alchemySettings);

export type NFT = {
name: string;
description: string;
imageUrl: string;
tokenUri?: string;
}

const NFTs = () => {
const [nfts, setNfts] = useState<NFT[]>([])
const [importing, setImporting] = useState(false)
const { authenticated, user } = usePrivy()

const importNFTs = async () => {
const address = user?.wallet?.address;
if (!address) {
console.log("No wallet address")
return;
}
setImporting(true)
try {
const nftsForOwner: any = await alchemy.nft.getNftsForOwner(address);
let nftsToSet: NFT[] = []
for (const n of nftsForOwner.ownedNfts) {
try {
console.log(n)
nftsToSet.push({
name: n.raw.metadata.name,
description: n.raw.metadata.description,
imageUrl: n.raw.metadata.image,
tokenUri: n.tokenUri
})
setNfts(nftsToSet)
setImporting(false)
} catch (error) {
console.log(error)
throw error;
}
}
} catch (error) {
console.log(error)
setImporting(false)
}
}

const backUpAllNFTs = async () => {
for(const nft of nfts) {
try {
await backUpSingleNFT(nft)
} catch (error: any) {
console.log(error)
alert(error.message)
}
}
}

const backUpSingleNFT = async (nft: NFT) => {}

return (
<div className="mt-4">
{nfts.length > 0 ?
<>
<button onClick={backUpAllNFTs} className="my-4 rounded-full px-8 py-2 bg-indigo-400 hover:bg-indigo-900 text-black font-md">Back up all</button>
<div className="grid md:grid-cols-3 lg:grid-cols-4 gap-2 grid-cols-1">
{
nfts.map((n: NFT) => {
return (
<div className="rounded-md border border-black shadow shadow-md">
<div className="bg-gray-700 w-full h-[65%]">
<img src={n.imageUrl} alt={n.name} className="w-auto h-full m-auto" />
</div>
<div className="p-6">
<h3 className="text-lg font-bold">{n.name}</h3>
<p className="mt-2">{n.description}</p>
<button className="mt-2 underline" onClick={() => backUpSingleNFT(n)}>Back up this NFT</button>
</div>
</div>
)
})
}
</div>
</> :
authenticated ?
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 576 512"><path d="M160 80H512c8.8 0 16 7.2 16 16V320c0 8.8-7.2 16-16 16H490.8L388.1 178.9c-4.4-6.8-12-10.9-20.1-10.9s-15.7 4.1-20.1 10.9l-52.2 79.8-12.4-16.9c-4.5-6.2-11.7-9.8-19.4-9.8s-14.8 3.6-19.4 9.8L175.6 336H160c-8.8 0-16-7.2-16-16V96c0-8.8 7.2-16 16-16zM96 96V320c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H160c-35.3 0-64 28.7-64 64zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120V344c0 75.1 60.9 136 136 136H456c13.3 0 24-10.7 24-24s-10.7-24-24-24H136c-48.6 0-88-39.4-88-88V120zm208 24a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z" /></svg>
<h1 className="mt-6 text-xl mb-6">No NFTs</h1>
<button onClick={importNFTs} className="rounded-full px-8 py-2 bg-indigo-400 hover:bg-indigo-900 text-black font-md" disabled={importing ? true : false}>{importing ? "Importing..." : "Import NFTs"}</button>
</div> :
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 576 512"><path d="M160 80H512c8.8 0 16 7.2 16 16V320c0 8.8-7.2 16-16 16H490.8L388.1 178.9c-4.4-6.8-12-10.9-20.1-10.9s-15.7 4.1-20.1 10.9l-52.2 79.8-12.4-16.9c-4.5-6.2-11.7-9.8-19.4-9.8s-14.8 3.6-19.4 9.8L175.6 336H160c-8.8 0-16-7.2-16-16V96c0-8.8 7.2-16 16-16zM96 96V320c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H160c-35.3 0-64 28.7-64 64zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120V344c0 75.1 60.9 136 136 136H456c13.3 0 24-10.7 24-24s-10.7-24-24-24H136c-48.6 0-88-39.4-88-88V120zm208 24a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z" /></svg>
<h1 className="mt-6 text-xl mb-6">No NFTs</h1>
<ConnectButton />
</div>
}
</div>
)
}

export default NFTs

We have added two new functions: backUpAllNFTs and backUpSingleNFT. We have also updated the UI. You can see when the NFTs are imported, we’re now rendering the image, name, and description. To make things easy, there’s a back up all NFTs button at the top, or the user can choose to back up individual NFTs.

We still need to write the code for backing up the NFTs, so let’s do that now.

Backing Up To Pinata With Groups

Let’s think about what we need to do to backup our NFTs. We need to make sure that the media is uploaded to IPFS, but we also need to make sure the metadata is uploaded as well. Fortunately, we have the tokenUri as part of the NFT object. So we will upload from URL the image and the token metadata.

It should be noted that this is only possible if the metadata and image was already on IPFS. If the metadata is on IPFS and the image is not, we can backup the metadata, but if neither is on IPFS, backing it up to IPFS doesn’t help the resiliency of the token. If you have tokens that aren’t on IPFS, you should ask the projects if it’s possible to migrate them to IPFS.

To get started with this process, let’s install a package that will help us determine if token metadata is on IPFS and if the image is on IPFS. Run the following in the terminal:

npm i @pinata/ipfs-gateway-tools

Pinata’s IPFS Gateway tools are simple but powerful. You can read the full docs here, but there are multiple pieces from the library that we’re going to use. In your nfts.tsx file, add this at the top:

//  @ts-ignore
import IPFSGatewayTools from "@pinata/ipfs-gateway-tools/dist/browser";

const gatewayTools = new IPFSGatewayTools();

We’re ignoring the Typescript validation because the library doesn’t have exported types. We could declare our own type file, and that’s preferable, but since this is a tutorial we’re taking the easy path.

Now, update your backupSingleNFT function to look like this:

const backUpSingleNFT = async (nft: NFT) => {
const { containsCid, cid } = gatewayTools.containsCID(nft.tokenUri)
if(containsCID) {
const convertedGatewayUrl = gatewayTools.convertToDesiredGateway(
nft.tokenUri,
process.env.NEXT_PUBLIC_GATEWAY_URL
);

const res = await const res = await fetch(`${convertedGatewayUrl}?pinataGatewayToken=your-gateway-token`)
const json = await res.json()
const { image } = json;
const { containsCid: containsImageCid, cid: imageCid } = gatewayTools.containsCID(nft.tokenUri)
if(containsImageCid) {
// Send image CID to be pinned by CID
}
// Send the metadata CID to backend to pinned by CID
}
}

We are using the gateway tools to determine if the metadata URI is an IPFS CID. If so, we’re going to back it up. We then convert it to a URL that uses our own gateway. You can get your gateway URL from your Pinata account. Go to the Gateways tab. The URL will be listed there. We need to add that to an .env file like this:

NEXT_PUBLIC_GATEWAY_URL=https://(your gateway url)

It’s important to understand that Pinata’s Dedicated IPFS Gateways default to only serving content stored on your own account. We’ll be fetching content from the wider IPFS network. For that, we need to make use of Access Controls. Go ahead and create a gateway access token for your gateway. You can see we’re using it in our request above to fetch the token metadata.

With that URL, we make a get request to load the actual token metadata from IPFS using our gateway. We parse that into JSON and grab the image property. Now, we need to do the same thing we did with our token metadata. We need to figure out if it’s an IPFS CID. If it is, we’ll send it to our backend to preserve it through our Pinata account.

We will preserve the token metadata CID and, if the image is found to be on IPFS, we’ll preserve that as well. But we haven’t written a backend yet. Let’s do that. The first thing we’re going to set up is our verification service. We don’t want just anyone making requests to our API. Only logged in users should be able to do so.

In the src directory, create a new file called auth.ts. Then add the following in that file:

import * as jose from 'jose';
const PRIVY_APP_ID = 'Your Privy app ID';
const PRIVY_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----${process.env.PRIVY_VERIFICATION_TOKEN}-----END PUBLIC KEY-----` || ""

export const verifySession = async (token: string) => {
try {
const verificationKey = await jose.importSPKI(PRIVY_PUBLIC_KEY, 'ES256');
const payload = await jose.jwtVerify(token, verificationKey, {
issuer: 'privy.io',
audience: PRIVY_APP_ID
});
// Verify that the sub matches app id
if(payload?.payload?.aud?.includes(PRIVY_APP_ID)) {
return true;
}
return false;
} catch (error) {
console.log(error);
return false;
}
}

You can immediately see we need to install another library. In your terminal, install the jose library by running:

npm i jose

This file is taking a JWT (which we’ll get from Privy on the frontend of the app and verifying it using our Privy App ID and a verification token. We’ll get that verification token from Privy by logging in and going to API Key and clicking on the Verify With Token tab. You’ll see a key between the text: -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----. We just want the key in between there.

Create a new ENV in your .env file like this:

PRIVY_VERIFICATION_TOKEN=Your key

Notice that this ENV is not prefixed with NEXT_PUBLIC_? That’s because this is a secret and should not be exposed to the browser or client. While we’re there, let’s add the Pinata JWT we got when creating an API key earlier. Do that like this:

PINATA_JWT=Your Pinata JWT

Now, with this set, we can set up our API endpoint and make authenticated requests to it. In the app directory create a new directory called api. Then create another directory inside the api directory called pinByCid. Inside that directory, add a file called route.ts. Add the following to your route.ts file:

import { verifySession } from '@/auth';
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers'

export const dynamic = 'force-dynamic'

type PostData = {
cid: string;
address: string;
}

export async function POST(request: NextRequest) {
try {
const { cid, address }: PostData = await request.json();
if (!cid) {
return new NextResponse(JSON.stringify({ error: 'CID is required' }), {
status: 400,
});
}

const headersList = headers()
const authHeader = headersList.get('authorization')
if (!authHeader) {
return new NextResponse(JSON.stringify({ error: 'Authorization header is missing' }), {
status: 401,
});
}

const token = authHeader.split("Bearer ")[1]
const validToken = verifySession(token)
if (!validToken) {
return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
});
}

// Create group if necessary
const resGroup = await fetch(`https://api.pinata.cloud/groups`, {
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.PINATA_JWT}`
},
body: JSON.stringify({
"name": address
})
})

const group = await resGroup.json()

const res = await fetch(`https://api.pinata.cloud/pinning/pinByHash`, {
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.PINATA_JWT}`
},
body: JSON.stringify({
"hashToPin": cid,
"pinataOptions": {
"groupId": group.id,
},
"pinataMetadata": {
"name": `${address} - ${cid}`,
"keyvalues": {}
}
})
})

return new NextResponse(JSON.stringify({ success: "true" }), {
status: 200,
});
} catch (error) {
console.log(error);
return new NextResponse(JSON.stringify({ error: "Server error" }), {
status: 500,
});
}
}

There are a few things going on in this file. First, we’re checking for the correct request body payload. We’re also verifying that an authorization header is present and the bearer token is valid. Using Pinata’s Groups feature, we are creating a Group if one doesn’t exist. The group is named based on the user’s wallet address. This will make it easy when we display backed-up NFTs.

With our group created and our group ID available, we are then pinning the CID using Pinata’s pin by CID functionality. This is convenient because it means Pinata will search the network and find the content then pin it to our account. We don’t have to manually download and re-upload content. IPFS for the win!

That’s it. We can now connect our frontend and make the post requests. Go back to your nfts.tsx file and create a new function called pinFile. That function should look like this:

const pinFile = async (cid: String) => {
try {
const accessToken = await getAccessToken();
await fetch(`/api/pinByCid`, {
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
cid: cid,
address: user?.wallet?.address
})
})
} catch (error: any) {
console.log(error)
alert(error.message)
}
}

The first thing you’ll notice is the getAccessToken function. This is a function from Privy’s hooks. So update your usePrivy hook to look like this:

const { authenticated, user, getAccessToken } = usePrivy()

We then use that token as our bearer token in the POST request. The body is simply our CID to pin and the wallet address of the logged in user.

Now, if you load your app, you can log in, import NFTs, then click the back up this NFT button on a single NFT or click the back up all NFTs button. This will work to back up all NFTs already on IPFS. It will skip NFTs using centralized server URLs. But we want to show the backed-up NFTs. This is where Pinata’s Groups feature shines.

We’re going to fetch all the files in the Group we created for the user and display them on the backups page. Let’s create the API for this. In your api directory, add a new directory called backups. Then inside it add a route.ts file. Inside that file, add the following:

import { verifySession } from '@/auth';
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers'

export const dynamic = 'force-dynamic'

export async function GET(request: NextRequest) {
try {
const address = request.nextUrl.searchParams.get("address")
if (!address) {
return new NextResponse(JSON.stringify({ error: 'Address is required' }), {
status: 400,
});
}

const headersList = headers()
const authHeader = headersList.get('authorization')

if (!authHeader) {
return new NextResponse(JSON.stringify({ error: 'Authorization header is missing' }), {
status: 401,
});
}

const token = authHeader.split("Bearer ")[1]
const validToken = verifySession(token)
if (!validToken) {
return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
});
}

// Get the group ID
const resGroup = await fetch(`https://api.pinata.cloud/groups?nameContains=${address}`, {
method: "GET",
headers: {
'Authorization': `Bearer ${process.env.PINATA_JWT}`
}
})

const groups = await resGroup.json()

if(!groups || groups.length === 0) {
throw new Error("Group not found")
}

const group = groups[0];

const res = await fetch(`https://api.pinata.cloud/data/pinList?pageLimit=100&groupId=${group.id}`, {
method: "GET",
headers: {
'Authorization': `Bearer ${process.env.PINATA_JWT}`
}
})

const files = await res.json()
return new NextResponse(JSON.stringify(files), {
status: 200,
});
} catch (error) {
console.log(error);
return new NextResponse(JSON.stringify({ error: "Server error" }), {
status: 500,
});
}
}

This is a GET endpoint that takes an address as a query string parameter. We validate that the address is provided and we validate that the bearer token is valid. Next, we get our group ID by using the Groups API and searching for the group named after our user’s wallet address. Once we have that, we use the ID to filter the pinList API and only get files in the group.

Let’s pause and consider the power of this. Pinata has always had a metadata system where you could add key-value pairs to files to try to reference them later. Groups is even more powerful. With a single group ID, we can get just the files in the group we specified. This is great for user-generated content and apps with segmented data.

Let’s finish up our backups.tsx frontend component. Update the entire file to look like this:

import { usePrivy } from '@privy-io/react-auth'
import React, { useEffect, useState } from 'react'
import { NFT } from './nfts'
// @ts-ignore
import IPFSGatewayTools from "@pinata/ipfs-gateway-tools/dist/browser";

const gatewayTools = new IPFSGatewayTools();
const Backups = () => {
const [backups, setBackups] = useState<NFT[]>([])
const { user, getAccessToken } = usePrivy()
useEffect(() => {
const loadBackups = async () => {
const token = await getAccessToken()
const res = await fetch(`/api/backups?address=${user?.wallet?.address}`, {
method: "GET",
headers: {
'Authorization': `Bearer ${token}`
}
})
const backupsData = await res.json()
let newBackups = []
for (const backup of backupsData?.rows) {
const cid = backup.ipfs_pin_hash
const gatewayRes = await fetch(`${process.env.NEXT_PUBLIC_GATEWAY_URL}/ipfs/${cid}?pinataGatewayToken=${process.env.NEXT_PUBLIC_GATEWAY_TOKEN}`)
const metadata = await gatewayRes.json()
const convertedGatewayUrl = await gatewayTools.convertToDesiredGateway(
metadata.image,
process.env.NEXT_PUBLIC_GATEWAY_URL
);
newBackups.push({
name: metadata.name,
description: metadata.description,
imageUrl: convertedGatewayUrl
})
setBackups([...backups, ...newBackups])
}
}
loadBackups()
}, [])
return (
<div>
{backups.length > 0 ?
<div>
<div className="mt-4 grid md:grid-cols-3 lg:grid-cols-4 gap-2 grid-cols-1">
{
backups.map((n: NFT) => {
return (
<div key={n.imageUrl} className="overflow-x-hidden rounded-md border border-black shadow shadow-md">
<div className="bg-gray-700 w-full h-[65%]">
<img src={`${n.imageUrl}?pinataGatewayToken=${process.env.NEXT_PUBLIC_GATEWAY_TOKEN}`} alt={n.name} className="w-auto h-full m-auto" />
</div>
<div className="p-6">
<h3 className="text-lg font-bold">{n.name}</h3>
<p title={n.description} className="mt-2">{n.description.slice(0, 80)}{n.description.length > 80 && "..."}</p>
</div>
</div>
)
})
}
</div>
</div> :
<div className="max-w-3/4 m-auto flex flex-col justify-center items-center min-h-[400px]">
<svg className="h-10 w-10" xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 448 512"><path d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z" /></svg>
<h1 className="mt-6 text-xl">No backups yet</h1>
</div>
}
</div>
)
}
export default Backups

We are loading the backed up NFTs on page mount. To do this, we are hitting our new backup API endpoint, but we are also using our gateway to get the JSON representation of the token metadata. Then we load all of the IPFS images through our gateway. We are still using a gateway access token because once you enable that on your gateway, you have to use it for all requests, even if the file is pinned to your account like these all are.

Conclusion

The most interesting part of this entire tutorial to me is how easy the Groups aspect was. It’s probably the most powerful part of the app, but used the least amount of code. It’s simple and effective.

Grouping files by user makes app development easy. This is how apps have always operated in web2. It’s been difficult to replicate in web3 with IPFS. Pinata Groups changes that.

Happy Pinning!

--

--

Justin Hunter
Pinata

Writer. Lead Product Manager, ClickUp. Tinkerer.