SEODojo

How to automate publishing to a Next.js blog

By the SEODojo team · Updated · 6 min read

Next.js doesn't have a built-in blog or admin screen, so there's no single "connect" button for it. How you publish depends on where your Next.js site keeps its posts. This guide covers the three common setups, shows the code each one needs, and ends with how to make the whole thing hands-off with Autopilot.

Pick the route that matches your site

Where your posts liveUse this in SEODojoWhat happens on publish
Markdown or MDX files in your repositoryGitHub destinationThe article is committed as a Markdown file; the commit triggers your normal deploy
Your own databaseWebhook destinationThe article is POSTed to an API route in your Next.js app, which saves it
A headless CMS (Sanity, Contentful, Strapi, Payload...)Webhook destination, through Zapier, Make, n8n or a small scriptThe article is created in the CMS; your site shows it like any other post
WordPress, Ghost, Notion or Webflow behind Next.jsThat service's own destinationThe post is created there directly

Not sure? Look for a folder like content/, posts/ or _posts/ full of .md or .mdx files in your repository. If it's there, use GitHub.

Option 1: Markdown files in your repository (GitHub)

This is the most common setup for Next.js blogs, and the simplest to automate: SEODojo commits each article as a Markdown file, and your host (Vercel, Netlify, Cloudflare or any other that builds on push) deploys it like any other change.

  1. 1Connect GitHub. In SEODojo, open Publish Queue, add a destination and choose GitHub. Connect with the GitHub App to grant access to just your blog's repository, or paste a fine-grained token with Contents: Read and write on it.
  2. 2Set the file path. Where each article goes, for example content/posts/{slug}.md. {slug}, {year}, {month} and {day} are filled in. Match the folder your site already reads.
  3. 3Set the branch. Leave it empty to use the default branch, which usually deploys straight to production. Use another branch if you'd rather review a preview deploy first.
  4. 4Set the public URL pattern. For example https://yourblog.com/blog/{slug}, so the Publish Queue links to the live post instead of the file on GitHub.
  5. 5Add extra front matter if your site needs it. Lines such as author: "Jane" or category: "guides" are added to every file.

What each file looks like

Every file has YAML front matter with title, date, slug and draft: false, plus any extra lines you set, followed by the article as Markdown. The article has no top-level heading (the title is in the front matter), uses ## for sections, and may start with a cover image.

content/posts/how-to-water-succulents.md

---
title: "How to Water Succulents"
date: 2026-10-04T09:30:00.000Z
slug: "how-to-water-succulents"
draft: false
author: "Jane"
---

![Succulents on a windowsill](https://seodojo.app/img/...)

Succulents store water in their leaves, so...

## How often to water

...

Render the files in Next.js

If your site already has a Markdown blog, point SEODojo's file path at its folder and you're done. If not, this is a minimal App Router setup. Install two small packages first: gray-matter reads the front matter, marked turns Markdown into HTML.

npm install gray-matter marked

lib/posts.ts

import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const POSTS_DIR = path.join(process.cwd(), "content/posts");

export type Post = { slug: string; title: string; date: string; content: string };

export function getAllPosts(): Post[] {
  if (!fs.existsSync(POSTS_DIR)) return [];
  return fs
    .readdirSync(POSTS_DIR)
    .filter((file) => file.endsWith(".md"))
    .map((file) => {
      const { data, content } = matter(fs.readFileSync(path.join(POSTS_DIR, file), "utf8"));
      return {
        slug: data.slug ?? file.replace(/\.md$/, ""),
        title: data.title,
        date: new Date(data.date).toISOString(),
        content,
        draft: data.draft === true,
      };
    })
    .filter((post) => !post.draft)
    .sort((a, b) => b.date.localeCompare(a.date));
}

app/blog/[slug]/page.tsx

import { notFound } from "next/navigation";
import { marked } from "marked";
import { getAllPosts } from "@/lib/posts";

// Every post is built into a static page at deploy time.
export function generateStaticParams() {
  return getAllPosts().map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getAllPosts().find((p) => p.slug === slug);
  return post ? { title: post.title } : {};
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getAllPosts().find((p) => p.slug === slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: await marked.parse(post.content) }} />
    </article>
  );
}

app/blog/page.tsx

import Link from "next/link";
import { getAllPosts } from "@/lib/posts";

export default function BlogIndex() {
  return (
    <ul>
      {getAllPosts().map((post) => (
        <li key={post.slug}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Using next/image or a strict Content Security Policy?

Cover images are hosted by SEODojo. Plain Markdown images work as they are, but if you render them with next/image, add the host from the image URLs (seodojo.app or app.seodojo.app) to images.remotePatterns in next.config. If your site sets a Content Security Policy, allow that host in img-src.

Option 2: A webhook to your own API route

If your posts live in a database, add one API route to your Next.js app and point SEODojo's Webhook destination at it. Each published article arrives as JSON; your route saves it and refreshes the pages that show it. Because the route runs on your server, this works on any host, including ones where the filesystem is read-only at runtime.

  1. 1Choose a signing secret. Any long random string. Add it to your site's environment variables as SEODOJO_WEBHOOK_SECRET.
  2. 2Add the route below and deploy it. It checks the signature, saves the article and returns the post's address.
  3. 3Connect the Webhook destination. In SEODojo, add a Webhook destination with your route's full https:// URL and the same signing secret. SEODojo sends a test "ping" when you connect; the route answers it with 200.

What SEODojo sends

A POST with a JSON body. The X-SeoDojo-Signature header is sha256= followed by the hex HMAC-SHA256 of the raw body, made with your signing secret. X-SeoDojo-Event repeats the event name.

{
  "event": "article.publish",
  "title": "How to Water Succulents",
  "slug": "how-to-water-succulents",
  "markdown": "Succulents store water in their leaves...",
  "html": "<p>Succulents store water in their leaves...</p>"
}

app/api/seodojo/route.ts

import crypto from "node:crypto";
import { revalidatePath } from "next/cache";

export async function POST(request: Request) {
  // Verify the signature against the raw body, before parsing it.
  const body = await request.text();
  const expected =
    "sha256=" + crypto.createHmac("sha256", process.env.SEODOJO_WEBHOOK_SECRET!).update(body).digest("hex");
  const given = request.headers.get("x-seodojo-signature") ?? "";
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return Response.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(body);
  if (event.event === "ping") return Response.json({ ok: true });
  if (event.event !== "article.publish") return Response.json({ ignored: true });

  const { title, slug, markdown, html } = event;
  await savePost({ title, slug, markdown, html }); // your database insert

  // Show the new post on the next visit.
  revalidatePath("/blog");
  revalidatePath(`/blog/${slug}`);

  // Optional: SEODojo records these, so the Publish Queue links to the live post.
  return Response.json({ id: slug, url: `https://yourblog.com/blog/${slug}` });
}
  • Any 2xx response is success: anything else shows as a failed publish in SEODojo, with the start of your response as the reason.
  • Don't redirect: SEODojo won't follow a redirect, so the signed article can't be sent somewhere else. Use the final URL, including www or not, exactly as your site serves it.
  • Answer within 15 seconds: save the post and respond; move slow work (image processing, notifications) to a background job.
  • Same slug twice: decide what your route does if a post with that slug exists: update it, or save under a new slug.

Option 3: Through your headless CMS

If your Next.js site reads posts from a headless CMS, publish into the CMS and let the site do what it already does. For WordPress, Ghost, Notion and Webflow, SEODojo has a destination for each, so connect that directly. For other CMSs, use the Webhook destination with an automation tool:

  1. 1Create a webhook trigger. in Zapier (Webhooks by Zapier, "Catch Hook"), Make ("Custom webhook") or n8n ("Webhook" node), and copy its URL.
  2. 2Add it as a Webhook destination in SEODojo. the test ping gives the tool a sample to map fields from.
  3. 3Map the fields to your CMS. title, slug, and either html or markdown, whichever your CMS's rich-text field accepts. Then add the step that creates the entry in your CMS.

If your CMS caches pages through Next.js, add its usual "revalidate on publish" webhook too, so new posts appear without waiting for the cache to expire.

Make it hands-off with Autopilot

Each option above sets where articles go. Autopilot decides what gets written and when: give it a list of topics or keywords and how often to post, and it drafts each article on schedule and checks it for SEO score, originality and factual claims.

  • Review first: drafts that pass the checks wait in the Publish Queue; you approve each one with a click. A good way to start.
  • Auto-publish: drafts that pass the checks are published straight to the destination chosen in Autopilot (your default unless you pick another). Any draft that doesn't pass, or fails to publish, goes to the Publish Queue for review instead, so nothing goes out unchecked.

Troubleshooting

  • The commit appears on GitHub but the post 404s: check that your deploy finished, and that the file path matches the folder your site reads. With generateStaticParams, a post only exists after a build that includes its file.
  • "GitHub couldn't find that repository": a fine-grained token must list the repository in its access settings; a GitHub App install must include it.
  • "The webhook answered HTTP 401": the signing secret in SEODojo and in your environment variables don't match, or the route verified a re-serialized body instead of the raw one.
  • "That URL redirects somewhere else": use the exact final address, for example with or without www and a trailing slash, as your site serves it.

Frequently asked questions

Does this work with the Pages Router?
Yes. The GitHub files and the webhook body are the same; read the files in getStaticProps and getStaticPaths instead of generateStaticParams, and put the webhook route in pages/api.
Can I use MDX instead of Markdown?
Yes: set the file path to end in .mdx. The articles are plain Markdown, which MDX accepts, though a stray < or { in the text can fail an MDX build. If you don't need components inside posts, .md is the safer choice.
Can SEODojo update or delete a post it published?
No. Publishing creates a new post, and it never overwrites an existing file: if the name is taken, it adds a short suffix to the slug. Edit or remove published posts in your repository, database or CMS.
Do I need Vercel?
No. The GitHub route works with any host that deploys when you push, and the webhook route works anywhere your Next.js app runs.

Related

Publish to your Next.js site on autopilot

SEODojo researches, writes and checks each article, then commits it to your repository or sends it to your API. Start free.

Start free