Pinata
Published in

Pinata

How To Build An NFT Token-Gated Blog with Ghost

Incorporating NFTs into your private blogging community

Setting Up Your Dev Environment

For this tutorial, we’re going to be working with Node.js, the command line, and Next.js. So, you’ll want to:

  1. A Next.js app to manage the backend and frontend of our token-gated blog
mkdir token-gated-blog && cd token-gated-blog
npx create-next-app blog
mkdir ghost-app
npm install ghost-cli@latest -g
cd ghost-blog
ghost install local

Connecting Ghost to Pinata

We want to make sure we can publish our posts to Pinata Submarine, but we also want to make sure that any media we add to our posts get uploaded to IPFS. So, let’s start implementing a Pinata storage adapter for Ghost.

cd content
mkdir adapters && cd adapters
mkdir storage && cd storage
git clone https://github.com/PinataCloud/pinata-ghost-storage
cd pinata-ghost-storage && npm i
"storage": {
"active": "pinata-ghost-storage",
"pinata-ghost-storage": {
"gatewayUrl": "https://yourgatewayurl.com",
"pinataKey": "YOUR PINATA API KEY",
"pinataSecret": "YOUR PINATA API SECRET"
}
},
ghost restart
  1. We want to token-gate content as opposed to just setting up a traditional membership wall.
cd ../blog
npm run dev
  1. updatePost.js
  2. deletePost.js
  3. getPosts.js
  4. [cid].js
SUBMARINE_KEY=YOUR SUBMARINE API KEY
DEDICATED_GATEWAY_URL=https://yourgateway.com
CONTRACT_ADDRESS=NFT CONTRACT ADDRESS
BLOCKCHAIN=Ethereum
NETWORK=Mainnet
npm i pinata-submarine
import { Submarine } from "pinata-submarine";const submarine = new Submarine(process.env.SUBMARINE_KEY, process.env.DEDICATED_GATEWAY_URL);export default async function handler(req, res) {
if(req.method === "POST") {
try {
const { post } = req.body;
const { current } = post;
const metadata = {
title: current.title,
feature_image: current.feature_image,
featured: current.featured,
excerpt: current.custom_excerpt,
tags: JSON.stringify(current.tags),
published: "true"
}
await submarine.uploadJson(current, current.id, 1, metadata);

res.send("Success");
} catch (error) {
console.log(error);
res.status(500).json(error);
}
}
}
import { Submarine } from "pinata-submarine";
const submarine = new Submarine(process.env.SUBMARINE_KEY, process.env.DEDICATED_GATEWAY_URL);
export default async function handler(req, res) {
if(req.method === "POST") {
try {
const { post } = req.body;
const { current } = post;
const options = {
name: current.id,
metadata: JSON.stringify({
published: "true"
})
}
const content = await submarine.getSubmarinedContent(options); if(content.length > 0) {
const { id } = content[0];
await submarine.deleteContent(id);
const metadata = {
title: current.title,
feature_image: current.feature_image,
featured: current.featured,
excerpt: current.custom_excerpt,
tags: JSON.stringify(current.tags),
published: "true"
}
await submarine.uploadJson(current, current.id, 1, metadata);
} else {
throw "No content found"
}
res.send("Success");
} catch (error) {
console.log(error);
res.status(500).json(error);
}
}
}
import { Submarine } from "pinata-submarine";
const submarine = new Submarine(process.env.SUBMARINE_KEY, process.env.DEDICATED_GATEWAY_URL);
export default async function handler(req, res) {
if(req.method === "POST") {
try {
const { post } = req.body;
const { previous } = post;
const options = {
name: previous.id,
metadata: JSON.stringify({
published: "true"
})
}
const content = await submarine.getSubmarinedContent(options); if(content.length > 0) {
const { id } = content[0];
await submarine.deleteContent(id);
} else {
throw "Content not found"
}
res.send("Success");
} catch (error) {
console.log(error);
res.status(500).json(error);
}
}
}
import { Submarine } from "pinata-submarine";
const submarine = new Submarine(process.env.SUBMARINE_KEY, process.env.DEDICATED_GATEWAY_URL);

export default async function handler(req, res) {
if(req.method === "GET") {
try {
const { offset, limit } = req.query;
const options = {
metadata: JSON.stringify({
ghostBlog: {
value: "true",
op: "eq"
}
}),
offset,
limit
}
const content = await submarine.getSubmarinedContent(options); if(content.length > 0) {
return res.json(content);
} else {
return res.json([]);
}
} catch (error) {
console.log(error);
res.status(500).json(error);
}
}
}

Build The Token-Gating App

Let’s talk about what this app needs to do. It needs to show a list of posts but not the content of those posts. Ghost has a nice excerpt feature, which if you use will be exposed as part of the JSON we’re storing with Pinata Submarine. So, if you have an excerpt, you might want to display that but not the whole post. The same is true for feature images, etc.

import Link from 'next/link';
import React from 'react';
const Posts = ({ post }) => {
const { metadata, createdAt, cid } = post;
const { title, excerpt } = metadata;
return (
<Link href={`/${cid}`}>
<div className="card cursor">
<h1>{title}</h1>
<p>{createdAt}</p>
<p>{excerpt}</p>
<h3>Read More</h3>
</div>
</Link>
)
}
export default Posts;
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import Authenticate from '../components/Authenticate';
import Post from '../components/Post';
const SinglePost = () => {
const [cid, setCID] = useState("");
const [authenticated, setAuthenticated] = useState(false);
const [postContent, setPostContent] = useState(null);
const router = useRouter(); const { query } = router; useEffect(() => {
if(query && query.cid) {
setCID(query.cid);
}
}, [query]);
if(authenticated) {
return <Post cid={cid} postContent={postContent} />
}
return (
<div>
<Authenticate cid={cid} setAuthenticated={setAuthenticated} setPostContent={setPostContent} />
</div>
)
}
export default SinglePost;
import { Submarine } from "pinata-submarine";
const submarine = new Submarine(process.env.SUBMARINE_KEY, process.env.DEDICATED_GATEWAY_URL);
export default async function handler(req, res) {
if (req.method === "GET") {
try {
const message = await submarine.getEVMMessageToSign(process.env.BLOCKCHAIN, process.env.CONTRACT_ADDRESS);
res.json(message);
} catch (error) {
console.log(error);
res.status(500).json(error);
}
} else {
try {
const { signature, messageId, address } = JSON.parse(req.body);
const ownsNFT = await submarine.verifyEVMNFT(
signature,
address,
messageId,
process.env.BLOCKCHAIN,
process.env.CONTRACT_ADDRESS,
process.env.NETWORK
);
if (ownsNFT) {
const { cid } = req.query;
const postData = await submarine.getSubmarinedContentByCid(cid);
const { id, metadata } = postData.items[0]; const link = await submarine.generateAccessLink(1000, id, cid); return res.json({
link,
id,
metadata,
});
} else {
return res.status(401).send("NFT not owned or invalid signature");
}
} catch (error) {
console.log(error);
const { response: fetchResponse } = error;
return res.status(fetchResponse?.status || 500).json(error.data);
}
}
}
import React from "react";
import Link from "next/link";
const Post = ({ postContent }) => {
return (
<div>
<Link href="/">Back</Link>
<div>
<h1>{postContent.title}</h1>
</div>
<div>
<div dangerouslySetInnerHTML={{ __html: postContent.html }} /
</div>
</div>
);
};
export default Post;

Wrapping Up

NFTs are powerful. They are an infinitely scalable accounting and distribution system. They are an authentication system. They open new markets. They can literally be anything. In this tutorial, we leveraged the power of NFT access and Pinata’s IPFS media(public and private) APIs to build a powerful app. And we even used the popular open source blogging app Ghost as our writing front end.

--

--

Get the Medium app

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