Back to blog
How to Use IPFS for Memecoins
I recently wrote about how to launch a memecoin on Farcaster, but there are, of course, plenty of memecoins that are not native to Farcaster. While not all memecoins share the same distribution mechanisms, many of them do share one important characteristic. The meme images associated with these tokens are stored on IPFS.
There are thousands and thousands of memecoins across all blockchains, but they behave differently. For example, a memecoin like Degen on Farcaster doesn’t have images associated with the individual tokens. Instead, Degen leverages images as a community-building tool. Contrast that with a platform like pump.fun, which allows anyone to launch a memecoin. Each memecoin launched requires an image. The goal is to create funny and engaging memes that are also tokens. Of course, there are variations that run the gamut between these two examples, but images are a center piece to all of them.
Today, we’re going to walk through how easy it is to upload and associated memecoin images with IPFS by using Pinata.
Getting Started
We’re going to use Pinata to upload memecoin images to IPFS, and we’re going to use a dedicated IPFS gateway to ensure these images load quickly and reliably every time, no matter where they are served. So, go ahead and sign up for a free Pinata account here.
While we will not be deploying smart contracts in this guide, we will show how you create the correct metadata to associate these memecoins with the tokens that will be deployed. We’ll do this for both Ethereum-based blockchains (EVM) and for Solana.
You’ll need a code editor, Node.js, and your computer’s terminal app to work on this project. Now, let’s get to it.
The Code
We’re going to use Next.js for this project. You could choose to generalize this and extract the pieces to allow you to do something a little more modular like React + Express or React + Cloudflare Workers if you wanted to, but for simplicity, Next.js gets us the frontend code and backend code in one place.
In this tutorial, we’re going to let people create their own memecoins. Again, we won’t be touching the blockchain side of the deployment, but we will be building out the meme creation and upload process. This will result in the metadata you need for each memecoin.
Let’s create our project using Next.js. Open your terminal and navigate to the folder where you keep all your projects. From there, run this command:
npx create-next-app
Give your project a name (I’m calling mine “memecoin-uploaded”) and follow the prompts. You can choose whatever you want for each prompt, but I’m going with the defaults which includes Typescript, App Router, Src directory, and no ES Lint.
Now, change into your project directory:
cd memecoin-uploader
If you’re not familiar with Next.js 14, take a look at the project structure. Everything happens inside the src
folder. Inside that folder, you have an app
directory where all the individual app pages will be created. For this, we’ll just have one page. But what about our API? We’ll need some backend code, so let’s make that happen.
Inside the src
folder create a new folder called api
. Create another folder inside the api
folder called upload
. Then, add a file inside the key
folder called route.ts
. This file is going to be where we make post requests to get a one-time use key for upload. We’ve written before about creating signed JWTs that will allow safe uploads from frontend clients. That’s exactly what we’ll be doing here.
We’ll need to generate an API key from Pinata to use within our backend code. So, go ahead and sign into Pinata and visit the API keys page. Create a new Admin key. When you’re done you’ll be provided a key, secret, and JWT. Grab the JWT and let’s add it to a .env
file inside the root of our project (create that file if it doesn’t exist yet):
PINATA_KEY=YOUR_JWT_HERE
Now, let’s use the Pinata API to create a one-time use key. Go back to your key/route.ts
file and add the following:
import { NextResponse } from "next/server";
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
try {
const keyRestrictions = {
keyName: 'Signed Upload JWT',
maxUses: 2,
permissions: {
endpoints: {
data: {
pinList: false,
userPinnedDataTotal: false
},
pinning: {
pinFileToIPFS: true,
pinJSONToIPFS: true,
pinJobs: false,
unpin: false,
userPinPolicy: false
}
}
}
}
const options = {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
authorization: `Bearer ${process.env.PINATA_KEY}`
},
body: JSON.stringify(keyRestrictions)
};
const jwtRepsonse = await fetch('https://api.pinata.cloud/users/generateApiKey', options);
const json = await jwtRepsonse.json();
const { JWT } = json;
return NextResponse.json({ message: { token: JWT } }, { status: 200 });
} catch (error) {
console.log(error)
return NextResponse.json({ message: "Server error" }, { status: 500 });
}
}
In this code, we set the key restrictions (i.e. the scopes) and we use that in the body of our API request to Pinata to create a key. Notice that we are only allowing pinFileToIPFS
and pinJSONToIPFS
endpoints, and the key’s limited use is 2 because we’ll need to upload two files. This means that, once it’s used, a bad actor cannot get ahold of it and use it over and over again to spam your account, making the key infinitely safer to use in the browser than an admin key.
One quick note: You’ll notice the line export const dynamic = 'force-dynamic'
. This ensures that Vercel, or whatever hosting provider you use, doesn’t cache the response from creating your API key. By default it will which means even though you think you’re creating a new key, you’ll just be getting back the previous cached one, which is absolutely not what we want.
Now, anyone can access your API in its current form, so you might want to introduce your own authentication on your app. If you do that, you would need to validate the auth token, or whatever is passed in, as part of the request before generating the key. As an example of this, we’ll use Dynamic to set up an embedded wallet on our app, and we’ll send a token generated by Dynamic that we can verify on the server.
Let’s go ahead and do that (and more) by moving to our frontend code.
In the src/app
folder, find the page.tsx
file. This is where we’re going to be setting up our app’s core functionality. Go ahead and remove everything in that file and replace it with this:
export default function Home() {
return (
<main className="min-h-screen bg-black text-white">
<div className="h-full flex flex-col justify-center w-screen">
</div>
</main>
);
}
Let’s think about what we need to do here. We need to check if a user has connected their wallet (this will be necessary for minting memecoins, which we won’t tackle here). If they are not, we need to let them connect using Dynamic. If they are, we need to present a form that let’s the user add a name, description, and image for their memecoin. Let’s start with implementing the Dynamic wallet logic.
Go to Dynamic’s website and sign up for a free account. Once you’ve done that, you’ll land on a dashboard with instructions. The instructions may change, so please refer to their guides. I’m including the instructions as of the time of writing this article. Go ahead and install the Dynamic SDK and dependencies into your project by running:
npm install @dynamic-labs/sdk-react-core @dynamic-labs/ethereum
Once that’s installed, we need to set up the Dynamic provider in our app. Update your src/app/page.tsx
file to look like this:
'use client'
import { DynamicContextProvider, DynamicWidget } from '@dynamic-labs/sdk-react-core';
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
export default function Home() {
return (
<DynamicContextProvider
settings={{
environmentId: 'YOUR DYNAMIC ENVIRONMENT ID',
walletConnectors: [ EthereumWalletConnectors ],
}}>
<DynamicWidget />
<main className="min-h-screen bg-black text-white">
<div className="h-full flex flex-col justify-center w-screen">
</div>
</main>
</DynamicContextProvider>
);
}
You’ll notice the only thing we changed here is we wrapped our entire app in the Dynamic provider. Once you have this set up, you can run your app and sign in with your favorite crypto wallet or create a wallet using your email. Complete the sign in flow on localhost, and you’ll see in your Dynamic dashboard that a user has been created.
We need to know if a user is connected or not so we can render the form. Let’s import Dynamic’s hook for managing login status. Add an additional import to the Dynamic React Core dependencies like this:
import { DynamicContextProvider, DynamicWidget, useIsLoggedIn } from '@dynamic-labs/sdk-react-core';
Then, right at the top of your function component, add this:
const isLoggedIn = useIsLoggedIn();
Now, we can simply check the LoggedIn
boolean to determine if we should allow memecoin creation. In between the empty div in your file, add the following:
<Form />
Then, above your Home component, create a function component called Form like this:
const Form = () => {
const isLoggedIn = useIsLoggedIn();
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [file, setFile] = useState<any>(null)
const fileRef: any = useRef()
const handleSubmit = async (e: any) => {
e.preventDefault()
}
const handleNameChange = (e: any) => {
setName(e.target.value)
}
const handleDescriptionChange = (e: any) => {
setDescription(e.target.value)
}
const handleFileClick = () => {
fileRef?.current?.click()
}
const handleFileChange = (e: any) => {
setFile(e.target.files[0])
}
return (
<div className="h-full flex flex-col justify-center w-screen">
{
isLoggedIn &&
<form onSubmit={handleSubmit}>
<div>
<label>
Name <br />
<input type="text" value={name} onChange={handleNameChange} />
</label>
</div>
<div>
<label>
Description <br />
<input type="text" value={description} onChange={handleDescriptionChange} />
</label>
</div>
<div>
<label>
File <br />
<input name="filename" className="hidden" ref={fileRef} type="file" accept="" onChange={handleFileChange} />
<p>{file?.name || ""}</p>
<button onClick={handleFileClick}>Select file</button>
</label>
</div>
<div>
<button type="submit">Upload</button>
</div>
</form>
}
</div>
)
}
This is a very simple form that will allow us to collect a name, description, and image file for our memecoin. You can actually fire up your app and test this by running:
npm run dev
You’ll be able to log in with Dynamic and select an image file. But we still need to upload the file and create the metadata necessary for our memecoin. Let’s do that now.
We have a handleSubmit
ready. Let’s use that. Update it to look like this:
const handleSubmit = async (e: any) => {
e.preventDefault()
const keyRes = await fetch(`/api/key`, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: null
})
const keyInfo = await keyRes.json()
if (keyInfo && keyInfo.message?.token) {
// Now we can upload
const formData = new FormData();
formData.append("file", file);
const metadata = JSON.stringify({
name: `${file.name}`,
});
formData.append("pinataMetadata", metadata);
const options = JSON.stringify({
cidVersion: 1,
});
formData.append("pinataOptions", options);
const uploadRes = await fetch(
"https://api.pinata.cloud/pinning/pinFileToIPFS",
{
method: "POST",
headers: {
Authorization: `Bearer ${keyInfo.message.token}`,
},
body: formData,
},
);
const cidData = await uploadRes.json()
console.log(cidData)
}
}
We’re calling the API route we created to generate an API key, then we’re using that key after we build up our form data to upload our image. Once the image is uploaded, you’re logging out the IPFS info. However, we want to do more than that. We need to construct the token’s metadata. We’ll do that next, but there’s one other point to understand here…
The request to our key generation API is unprotected. You can use Dynamic to get an auth token and verify it, or you can use some other method of your choice, but you should not leave that API endpoint unrestricted in a production environment.
Ok, let’s create some metadata.
EVM ERC20 Metadata
For an ERC20 token on an EVM (Ethereum Virtual Machine) compatible blockchain, you’ll want your token’s metadata to be formatted like this:
{
"name": "Token Name",
"description": "Token Description",
"external_url": "Any website",
"decimals": 18, //total decimal points to support for coin, the more decimals, the smallet the possible denomination
"image": "IPFS URI for image"
}
This isn’t quite a “standard,” like NFT token metadata is, but many ERC20 project follow this format for uniformity. This metadata will be turned into a contract URI that can be called by anyone who wants to see it. Knowing this, we can take the data we have for our token creation and create a contractURI
from similarly shaped JSON.
Below where we logged out the upload response for our image, add the following:
const contractUriJSON = {
name: name,
description: description,
external_url: "https://pinata.cloud",
decimals: 18,
image: `ipfs://${cidData.IpfsHash}`
}
const jsonRes = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
method: "POST",
headers: {
'Autorization': `Bearer: ${keyInfo.message.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
pinataOptions: {
cidVersion: 1,
},
pinataMetadata: {
name: "metadata.json"
},
pinataContent: contractUriJSON
})
})
const uriData = await jsonRes.json()
console.log(uriData)
We’re creating a JSON object that matches the metadata format we showed above. Then we are using the same limited time use key we already generated to upload the JSON to IPFS. Then, we are logging out the response.
What does this look like for Solana?
Solana SPL Metadata
Solana does have a defined metadata standard for its tokens. This is very similar to NFTs, and it’s not too far off from the ERC20 object we created above. For a Solana SPL token, you’ll need to create an object that looks like this:
{
"name": "YOUR TOKEN NAME",
"symbol": "YOUR TOKEN SYMBOL",
"description": "YOUR TOKEN DESCRIPTION",
"image": "YOUR IPFS URI",
"attributes": [] //Option attributes
}
We’re going to skip the attributes in our token and keep it simple. We don’t have a token symbol in our form, so we’ll hardcode that, but you could add one more field to create a symbol. Here’s how this would look in our code. Below the console log that shows the response from uploading the image, add the following:
const splJSON = {
name: name,
symbol: "TKN",
description: description,
image: `ipfs://${cidData.IpfsHash}`
}
const jsonRes = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
method: "POST",
headers: {
'Autorization': `Bearer: ${keyInfo.message.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
pinataOptions: {
cidVersion: 1,
},
pinataMetadata: {
name: "metadata.json"
},
pinataContent: contractUriJSON
})
})
const uriData = await jsonRes.json()
console.log(uriData)
That’s it. Similar to the ERC20 flow, we upload the JSON to IPFS and we now have a URI we can use in our token’s contract.
What’s next?
In a future tutorial, we’ll show you how to deploy an ERC20 token contract as well as a Solana SPL token. For now, though, you have everything you need to start building tools that will get your memecoin’s media hosted on IPFS to preserve the public nature of the coin.
This tutorial covered using Dynamic for wallet access, Next.js for server and client code, and Pinata for hosting images and JSON on IPFS. Now, you can create your own custom memecoins. When you are ready to display the memes associated with these coins, you simply need to fetch the image from IPFS using your dedicated gateway. If you remember, the image’s URI format uses the IPFS protocol URI like this: ipfs://CID
. This creates maximum flexibility because you or anyone else can use any gateway they’d like to load the image. The dedicated IPFS gateway that comes with your Pinata account will be the fastest and most reliable way to load this content. It has a built in CDN and image optimization tools.
You’ll be well equipped for memeing to your heart’s content. Happy Pinning!