Creating a Human vs AI Auto Blogger - Part 3 - Setting up OpenAI to Auto Blog in Next Js

Creating a Saas Auto Blogger - Part 3 - Setting up OpenAI to Auto Blog in Next Js
** Please see Part 2 before proceeding **
Part 3 - OpenAI creates posts automatically but..
Use this at your own risk, the posts that Open AI create just aren’t there yet. Maybe you could tweak the prompt, but either way it still gives a starting point for doing something more like referencing database products, menu items, or appointments. If you just need assistance overcoming writer’s block, Notion AI seems useful for sentence completion or a project like Novel.sh by Steven Tey.
With that said, let’s create the auto blogger!
First we’ll need an OpenAI key, this can be done by signing up and getting a API key from platform.openai.com/apikeys then at it to your .env file as
OPENAI_API_KEY
Next, we’ll install the npm packages we’ll need
npm i openai
With that installed, let’s change our contentlayer.config.ts file in our root directory and change it to contentlayer.config.js file. Your tsconfig.json might scream at you, just open the file and click save to brush it off.
Now in the contentlayer.config.js, update it to the following:
import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer/source-files'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
/** @type {import('contentlayer/source-files').ComputedFields} */
const Image = defineNestedType(() => ({
name: 'Image',
fields: {
width: { type: 'number', required: true },
height: { type: 'number', required: true },
src: { type: 'string', required: true },
},
}))
export const Portfolio = defineDocumentType(() => ({
name: 'Portfolio',
filePathPattern: `portfolio/**/*.mdx`,
contentType: "mdx",
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
slug: { type: 'string', required: true },
notionId: { type: 'string', required: false },
tags: {type: 'list', of: {type: 'string'}},
image: {type: 'nested', of: Image, required: true },
localisation: { type: 'string', required: false},
enabled: { type: 'boolean', required: false}
}
}))
const computedFields = {
slug: {
type: "string",
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
slugAsParams: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/"),
},
}
export const Page = defineDocumentType(() => ({
name: "Page",
filePathPattern: `pages/**/*.mdx`,
contentType: "mdx",
fields: {
title: {
type: "string",
required: true,
},
description: {
type: "string",
},
},
computedFields,
}))
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `posts/**/*.mdx`,
contentType: "mdx",
fields: {
title: {
type: "string",
required: true,
},
domain: {
type: "string",
},
description: {
type: "string",
},
date: {
type: "date",
required: true,
},
},
computedFields,
}))
export default makeSource({
contentDirPath: 'src/content',
documentTypes: [ Portfolio, Post, Page],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypePrettyCode,
{
// Use one of Shiki's packaged themes like 'vitesse-light'
theme: "vitesse-dark",
// Set to true to keep the background color
keepBackground: true ,
onVisitLine(node) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
onVisitHighlightedLine(node) {
node.properties.className.push("highlighted");
},
onVisitHighlightedWord(node, id) {
node.properties.className = ["word"];
},
}
],
[
rehypeAutolinkHeadings,
{
properties: {
className: ['subheading-anchor'],
ariaLabel: 'Link to section',
}
}
],
]
}
});
You will notice I added a couple more fields to the types recieved in Portfolio but the main addition here is Post and Page. This is how we will name our AI generated posts. Add the following folders to the content folder for our generated markdown posts:
src/content/posts
src/content/pages
We’ll also need a little utility to format the date, lets add that to src/lib/utils.ts where our shadcn installation already made us a file.
//src/lib/utils.ts
export function formatDate(input: string | number): string {
const date = new Date(input)
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
Let’s now make some components for the mdx. Create two new files:
//src/components/mdx-component.tsx
//src/components/mdx-component.tsx
import Image from "next/image"
import { useMDXComponent } from "next-contentlayer/hooks"
const components = {
Image,
}
interface MdxProps {
code: string
}
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code)
return <Component components={components} />
}
The next is a simple card for the mdx:
//src/components/mdx-card.tsx
//src/components/mdx-card.tsx
import Link from "next/link"
import { cn } from "@/lib/utils"
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
href?: string
disabled?: boolean
}
export function MdxCard({
href,
className,
children,
disabled,
...props
}: CardProps) {
return (
<div
className={cn(
"group relative rounded-lg border p-6 shadow-md transition-shadow hover:shadow-lg",
disabled && "cursor-not-allowed opacity-60",
className
)}
{...props}
>
<div className="flex flex-col justify-between space-y-4">
<div className="space-y-2 [&>h3]:!mt-0 [&>h4]:!mt-0 [&>p]:text-muted-foreground">
{children}
</div>
</div>
{href && (
<Link href={disabled ? "#" : href} className="absolute inset-0">
<span className="sr-only">View</span>
</Link>
)}
</div>
)
}
With those in place, let’s add some pages to handle the posts with a simple list:
//src/app/(blog)/posts/page.tsx
//src/app/(blog)/posts/page.tsx
import { allPosts } from ".contentlayer/generated"
import Link from "next/link"
export default function Posts() {
return (
<div className="mx-auto max-w-5xl mt-10">
<h2 className="text-center font-black text-5xl">AI Generated Posts</h2>
<div className="mt-20 prose dark:prose-invert space-y-5 mx-20">
{allPosts.map((post) => (
<article key={post._id}>
<Link href={post.slug}>
<h2 className="text-lg font-semibold">{post.title}</h2>
</Link>
{post.description && <p className="font-light text-sm">{post.description}</p>}
</article>
))}
</div>
</div>
)
}
And another for post page itself:
//src/app/(blog)/posts/[…slug]/page.tsx
//src/app/(blog)/posts/[…slug]/page.tsx
import { notFound } from "next/navigation"
import { allPosts } from "contentlayer/generated"
import { ChevronLeft, ChevronLeftCircle, EyeIcon, View } from "lucide-react";
import { Metadata } from "next"
import { Mdx } from "@/components/mdx-components"
import Link from "next/link"
import { cn, formatDate } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"
interface PostProps {
params: {
slug: string[]
}
}
async function getPostFromParams(params: PostProps["params"]) {
const slug = params?.slug?.join("/")
const post = allPosts.find((post) => post.slugAsParams === slug)
if (!post) {
null
}
return post
}
export async function generateMetadata({
params,
}: PostProps): Promise<Metadata> {
const post = await getPostFromParams(params)
if (!post) {
return {}
}
return {
title: post.title,
description: post.description,
}
}
export async function generateStaticParams(): Promise<PostProps["params"][]> {
return allPosts.map((post) => ({
slug: post.slugAsParams.split("/"),
}))
}
export default async function PostPage({ params }: PostProps) {
const post = await getPostFromParams(params)
if (!post) {
notFound()
}
const views = 22;
return (
<div>
<div className=" relative w-full min-h-[calc(100vh-600px)] border-b-[0.1px] border-accent mt-[-20px] py-10 z-20 ml-0">
<div className="absolute top-0 z-[-2] h-full w-screen bg-[#000000] bg-[radial-gradient(#ffffff33_1px,#00091d_1px)] bg-[size:20px_20px]"></div>
<div className="absolute top-[-150px] left-0 bg-gradient-to-br from-purple-400/20 blur-lg via-transparent to-transparent w-screen h-[450px]"></div>
<div className="absolute top-0 left-0 right-0 h-[500px] w-[500px] rounded-full bg-[radial-gradient(circle_farthest-side,rgba(255,0,182,.15),rgba(255,255,255,0))]"></div>
<div className='mb-10'/>
<div className="flex flex-col justify-end items-end px-10">
{post.date && (
<time
dateTime={post.date}
className="block ml-auto text-md text-muted-foreground"
>
<EyeIcon className="inline w-4 h-4 text-muted-foreground mr-1" /> {views} <span className="mx-1"></span> {` `} ·{" "} <span className="mx-1"></span> {formatDate(post.date)} <span className="mx-1"></span> {` `} ·{" "}
<span className="mx-1"></span>
</time>
)}
<h1 className="flex mt-2 text-6xl sm:text-7xl max-w-5xl font-heading text-right md:text-8xl lg:text-9xl leading-tight ml-auto dark:text-transparent dark:bg-clip-text dark:bg-gradient-to-tr dark:from-zinc-400/10 dark:via-white/90 dark:to-white/10">
{post.title}
</h1>
<p className="text-muted-foreground max-w-5xl mr-1 text-right text-xl md:text-xl">{post.description}</p>
</div>
<div className="flex justify-center mt-3 items-center">
<Link
href="/posts"
className={cn(
buttonVariants({ variant: "ghost" }),
"inline-flex hover:bg-transparent"
)}
>
<ChevronLeft className="mr-2 w-4 h-4" />
See all posts
</Link>
</div>
</div>
<article className="py-6 prose dark:prose-invert">
<h1 className="mb-2">{post.title}</h1>
{post.description && (
<p className="text-xl mt-0 text-slate-700 dark:text-slate-200">
{post.description}
</p>
)}
<hr className="my-4" />
<Mdx code={post.body.code} />
</article>
</div>
)
}
You will notice I hardcoded the views = 22, this can be replaced by view counter that I might address in future blog posts, but for now lets leave it.
To test, add an mdx file you created with Notion to your src/content/posts folder and when you run the local server
npm run dev
We should see your post listed in http://localhost:3000/posts
Alright, lets add OpenAI generation. First lets add the parameters you want OpenAi to abide by, so make a settings.json in the root of your project (same level as contentlayer.config.js) and add the following:
//settings.json
{
"domain": "Next Js Programming",
"tone": "Passionate and Urgent",
"length": "Long",
"numArticles": 4,
"skill": "Intermediate",
"topics": [
"Introduction to Next Js",
"Next Js Data Types and Variables",
"Next Js Control Structures",
"Next Js Functions"
]
}
Here is where you can configure the number of articles, the topics…etc.
To actually generate from this config, it is as simple as running a generate script like we did with notion. Go to your scripts folder and add generate.js beside the notion.js. Inside generate.js, add:
// Import required packages
const OpenAI = require("openai");
const dotenv = require("dotenv");
const fs = require("fs");
const slugify = require("slugify");
// Load the API key from the .env file
dotenv.config();
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// Load properties from the settings.json file
let properties = JSON.parse(fs.readFileSync("settings.json", "utf-8"));
const defaultProps = {
type: "Course",
task: "Blog Article Writing",
tone: "Passionate and Urgent",
length: "Medium",
domain: "Next.js",
topic: "Getting started with Next.js: A beginner's guide",
lang: "English",
skill: "Intermediate",
numArticles: 3
};
properties = { ...defaultProps, ...properties };
const usedSubjects = new Set();
async function readSubjectsFromFiles() {
const directory = "src/content/posts";
const files = fs.readdirSync(directory);
files.forEach((file) => {
if (file.endsWith(".mdx")) {
const content = fs.readFileSync(`${directory}/${file}`, "utf-8");
const descriptionMatch = content.match(/^description:\s*(.+)$/m);
if (descriptionMatch) {
usedSubjects.add(descriptionMatch[1]);
}
}
});
}
async function generateCoverImageUrl(keyword, accessKey) {
const response = await fetch(`https://api.unsplash.com/photos/random?query=${keyword}&client_id=${accessKey}`);
const data = await response.json();
return data.urls.regular;
}
async function generateDescription(subject) {
const prompt = `Generate an article short description for the subject ${subject} . Max allowed number of words is 40. Don't use the following characters: ":;{}[]()<>\/"`;
const response = await openai.completions.create({
model: "gpt-3.5-turbo-instruct",
max_tokens: 64,
prompt,
temperature: 0.3
});
return response.choices[0].text.trim();
}
async function generateSubject(domain) {
const prompt = `Generate a blog article subject for the domain: ${domain}. Avoid these subjects: ${Array.from(usedSubjects).join(", ")}. Don't use the following characters :;{}[]()<>\"'`;
const response = await openai.completions.create({
model: "gpt-3.5-turbo-instruct",
max_tokens: 512,
prompt,
temperature: 0.7
});
return response.choices[0].text.trim();
}
async function generateUniqueSubject(domain) {
let subject;
do {
subject = await generateSubject(domain);
} while (usedSubjects.has(subject));
usedSubjects.add(subject);
return subject;
}
async function generateArticle(properties) {
let promptParts = [
properties.type,
properties.task,
properties.tone,
properties.length,
`on ${properties.domain}`,
`with the topic ${properties.topic}`,
`writing in ${properties.lang}`,
`to an audience with ${properties.skill} skill level`,
`Provide code examples where applicable`,
`include official references to ${properties.topic} with a link if applicable`,
`include a cover image from ${properties.imgUrl} as a centered cover image`,
`use markdown syntax`
];
const filteredPromptParts = promptParts.filter((part) => part.trim() !== "");
const prompt = "Write a " + filteredPromptParts.join(", ") + ".";
const response = await openai.completions.create({
model: "gpt-3.5-turbo-instruct",
max_tokens: 2048,
prompt,
temperature: 0.7
});
return response.choices[0].text.trim();
}
async function saveArticle(title, description, content) {
const slug = slugify(title, { lower: true, strict: true });
let fileName = `src/content/posts/${slug}.mdx`;
const currentDate = new Date().toISOString().slice(0, 10);
const articleContent = `---
title: ${title}
date: "${currentDate}"
description: ${description}
---
${content}`;
fs.writeFileSync(fileName, articleContent);
console.log(`Saved article: ${fileName}`);
}
(async () => {
await readSubjectsFromFiles();
const domain = properties.domain;
let subject = await generateUniqueSubject(domain);
let numArticles = parseInt(properties.numArticles);
if(properties.topics && properties.topics.length > 0) {
numArticles = properties.topics.length;
}
for (let i = 0; i < numArticles; i++) {
if(properties.topics && typeof properties.topics[i] !== 'undefined') {
subject = properties.topics[i];
}
console.log("Generated Subject:", subject);
properties.topic = subject;
let description = await generateDescription(subject);
console.log("Generated Description:", description);
const imgUrl = await generateCoverImageUrl(subject, process.env.UNSPLASH_ACCESS_KEY);
properties.imgUrl = imgUrl;
const article = await generateArticle(properties);
saveArticle(subject, description, article);
}
})();
What is happening here:
-
First we parse our settings for the blog posts in settings.json we created.
-
We define props for the all encompassing function we will be calling later (generateArticle). We individually generate each part subject and description.
//function generateArticle(properties)
properties.type,
properties.task,
properties.tone,
properties.length,
`on ${properties.domain}`,
`with the topic ${properties.topic}`,
`writing in ${properties.lang}`,
`to an audience with ${properties.skill} skill level`,
`Provide code examples where applicable`,
`include official references to ${properties.topic} with a link if applicable`,
`include a cover image from ${properties.imgUrl} as a centered cover image`,
`use markdown syntax`
-
The image is just fetched using our subject as keyword in a Unsplash API call.
-
The saveArticle function is where we pass everything we have generated as well as some hard values from settings config.
We need an Unsplash API key so we can add images to our post. Go to Unsplash and register as a developer. When you get your API key from the dashboard, save it in your .env as:
UNSPLASH_ACCESS_KEY=
Go to your settings.json and set 4 article names to what you like and the number of articles to 4. Ill let you set the rest.
Now for the moment of truth, lets see what it generates. In the terminal type:
node ./scripts/generate.js
You should see a few new articles in your src/content/posts folder:
$ node ./scripts/generate.js
Generated Subject: Introduction to Next Js
Generated Description: ..
Saved article: src/content/posts/introduction-to-next-js.mdx
Generated Subject: Next Js Data Types and Variables
Generated Description: ..
Saved article: src/content/posts/next-js-data-types-and-variables.mdx
Generated Subject: Next Js Control Structures
Generated Description: ..
Saved article: src/content/posts/next-js-control-structures.mdx
Generated Subject: Next Js Functions
Generated Description: …
Saved article: src/content/posts/next-js-functions.mdx
If you are getting an error, try updating the model from gpt-3.5-turbo-instruct to the latest recommended replacement model by Open AI. Change the number of max-tokens to generate more content per page.
And that’s it folks! Hope you stuck around to see the magic that AI can create from a few lines of prompting. The output has much to be desired but like I said, it’s a good starting point to so much more.
Congrats on finishing Part 3!
See Part 4 to learn how to integrate a view count and a script to calculate minutes to read post function.