Node.js / Next.js
@pengraph/node is the official Node.js client for SEOcondo. It reads blog posts you have already published in the SEOcondo dashboard and returns them as JSON, so your own website can display them.
The website you sign up for is SEOcondo. The npm package is @pengraph/node. Writing, editing, and publishing happen in the dashboard. This client only fetches posts whose status is Published. Drafts and posts in review are not returned.
Run it on a server. The API key is a secret. Do not put it in browser JavaScript, and do not name the variable NEXT_PUBLIC_PENGRAPH_API_KEY.
The same walkthrough ships inside the package README, which is what npm shows.
1. Create an account
- Open https://seocondo.com/auth?mode=signup. The heading is Create your account.
- Click Continue with Google, or enter Full name, Email, and a Password of at least 8 characters, then click Create account.
- Email sign-up shows Check your email. Open the confirmation link in that message. It signs you in and opens the dashboard. Use send it again on that screen if the message does not arrive.
- Later visits use https://seocondo.com/auth on the Log in tab.
New accounts start with a 14-day free trial.
2. Add a website
A site ID belongs to one website you added in SEOcondo.
- Open https://seocondo.com/dashboard/sites/new. The title is Add a website.
- Fill in Website address, Business name, Industry, Language, and Where your customers are. Click Continue.
- SEOcondo reads the site (about a minute). If that fails, Continue without it is available.
- On How you sound, click Continue.
- On What it’s for, choose a Main goal and a Usual post length, then click Finish.
If the plan’s site limit is already used, the page says so instead of showing the form. Check Account settings → Plan.
3. Publish a post
An empty API result is what you get until a post is published.
- Open the site, then Posts, then New post.
- Open the post and click Publish.
- In the dialog Publish this post?, click Publish.
4. Copy the site ID
The site ID is a UUID, for example 7c9e6679-7425-40de-944b-e07fc1f90ae7. It is not a secret. The API key is.
- Open Account settings → Connections. The tabs are Profile, Connections, Plan, and Notifications.
- On the Site API card, choose the site in the Site dropdown.
- Under the dropdown, the page prints Site ID and the UUID. That UUID is
PENGRAPH_SITE_ID.
The same UUID is in the address bar when you are inside the site:
https://seocondo.com/dashboard/sites/<site-id>/posts
If the card says Add a site, finish the step above before creating a key.
5. Create an API key
A key only works for the site that was selected when you created it. It always starts with pg_live_.
- Stay on Site API with that site selected.
- In New key name, type a label such as
Production website. The name is not the secret. The field starts asProduction. - Click Create key.
- The box Copy your key now shows the full key once. The line under it says the key will not be shown again. Click Copy and store it immediately.
- The list afterwards shows the name and a prefix such as
pg_live_abc123…. That prefix will not authenticate. If you missed the full key, create another one.
Revoke makes every server still using that key fail with 401 Invalid API key. Update the server with a new key before revoking the one it uses.
6. Install
In the website project that will display the posts (Node.js 18 or newer):
npm install @pengraph/node
# or: yarn add @pengraph/node
# or: pnpm add @pengraph/node
The package has zero runtime dependencies. You do not install the SEOcondo server’s other packages.
7. Environment variables
For local Next.js, put this in .env.local next to package.json. Do not commit that file. Restart the dev server after editing it.
PENGRAPH_SITE_ID=7c9e6679-7425-40de-944b-e07fc1f90ae7
PENGRAPH_API_KEY=pg_live_paste_the_full_key_here
| Variable | Required | What to put |
|---|---|---|
PENGRAPH_SITE_ID | Yes | The site UUID from the Connections page |
PENGRAPH_API_KEY | Yes | The full pg_live_… key from Copy your key now |
PENGRAPH_BASE_URL | No | Leave unset. The client then calls https://seocondo.com. Set it only when you run your own SEOcondo server. |
The client does not read PENGRAPH_SITE_ID or PENGRAPH_API_KEY from the environment. Your code passes them in. A missing value throws siteId is required or apiKey is required before any request.
There is no baseUrl option on createPenGraphClient. Only PENGRAPH_BASE_URL changes the host.
On a host such as Vercel, add the same two variables in the project’s environment settings and redeploy.
8. Check the key before building a page
Node.js 20 or newer:
node --env-file=.env.local --input-type=module -e '
import { createPenGraphClient } from "@pengraph/node";
const client = createPenGraphClient({
siteId: process.env.PENGRAPH_SITE_ID,
apiKey: process.env.PENGRAPH_API_KEY,
});
const { posts, pagination } = await client.getPosts({ limit: 5 });
console.log(pagination.total, "published posts");
console.log(posts.map((p) => p.slug));
'
0 published posts means the key worked and nothing is published yet. Invalid API key means the key and site ID are not a pair, the key was revoked, or you pasted the prefix instead of the full key.
Use it in Next.js
Clara doesn't only write articles. She rewrites them, merges duplicates, retires weak ones, adds
redirects and tells search engines about each change. The Next.js add-on
(@pengraph/node/next, version 0.3.0 or later) brings all of that to your site with five small
files. Hand this section to your developer.
1. lib/blog.ts: where your articles live.
import { createPenGraphNext } from "@pengraph/node/next";
// Reads PENGRAPH_SITE_ID and PENGRAPH_API_KEY from the environment.
export const blog = createPenGraphNext({
siteUrl: "https://www.example.com",
basePath: "/blog", // or "/news", wherever articles live
});
2. app/blog/[slug]/page.tsx: one article. Articles Clara merged or retired forward to their
replacement instead of showing "page not found".
import { notFound, permanentRedirect } from "next/navigation";
import { blog } from "@/lib/blog";
export const dynamic = "force-dynamic";
type Props = { params: Promise<{ slug: string }> };
export const generateMetadata = ({ params }: Props) => blog.generateMetadata(params);
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const found = await blog.getPost(slug);
if (found?.moved) permanentRedirect(found.moved.to);
if (!found) notFound();
const { post } = found;
return (
<article>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(blog.jsonLd(post)) }} />
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content_html || "" }} />
</article>
);
}
3. app/sitemap.ts: every article with the date it last changed, so Google re-reads Clara's rewrites.
import { blog } from "@/lib/blog";
export default () => blog.sitemap([{ url: "https://www.example.com/" }]);
4. app/llms.txt/route.ts: a guide to your site for AI assistants.
import { blog } from "@/lib/blog";
export const GET = blog.llmsTxtRoute();
5. next.config.ts: redirects for moved articles, and the IndexNow key file that lets Clara
tell Bing and other search engines the moment an article changes.
import type { NextConfig } from "next";
import { blog } from "./lib/blog";
const config: NextConfig = {
redirects: () => blog.redirects(),
rewrites: () => blog.rewrites(),
};
export default config;
generateMetadata uses the title, Google description, canonical and social preview Clara wrote.
blog.url(post) gives an article's link for your list page; blog.client is the plain client.
Not using the add-on? The plain client has the same pieces: client.resolvePost(slug) returns
{ post }, { moved: { to, statusCode } } or null; plus client.getRedirects(),
client.getIndexNowKey(), client.getAllPosts(), and the helpers postMetadata, postJsonLd,
postUrl and sitemapEntries. getPostBySlug still returns null for a missing or moved article.
Other failures throw PenGraphError (status, message, body).
Upgrading to 0.3.0
Already using @pengraph/node 0.1 or 0.2? Upgrading takes about 15 minutes and nothing you have
breaks: getPostBySlug and everything else work as before. What you gain is that your site keeps
up with Clara:
- Articles she merges or retires forward to their replacement instead of showing "page not found".
- Her redirects are applied.
- Search engines hear about her changes straight away (the IndexNow key file).
- Your sitemap shows when each article last changed, so Google re-reads her rewrites.
- Her titles, descriptions and structured data reach the page head.
Option 1: let an AI coding assistant do it
Open your website's code in Cursor, Claude Code, GitHub Copilot or a similar assistant, and paste this prompt as it is:
Upgrade this website's SEOcondo integration to @pengraph/node 0.3.0.
Context: SEOcondo's assistant, Clara, writes the blog articles this site shows, and she also changes them over time: she rewrites articles, merges duplicates, retires weak ones (their old addresses must forward with a 301), adds redirects, and tells search engines about each change through IndexNow. Version 0.3.0 of @pengraph/node brings all of that to the site. Docs: https://www.npmjs.com/package/@pengraph/node
Do this:
1. Upgrade the package: `npm install @pengraph/node@^0.3.0` (use the project's package manager).
2. Find every place the site uses @pengraph/node (createPenGraphClient, getPosts, getPostBySlug, getLlmsTxt, getOrganizationSchema). Keep PENGRAPH_SITE_ID and PENGRAPH_API_KEY as they are; they must stay server-side only.
3. Work out where articles live on this site (the URL path before the slug, e.g. /blog or /news) and the site's public address (e.g. https://www.example.com). Read them from the existing routes and config; don't guess.
4. If this is a Next.js App Router site:
a. Create `lib/blog.ts` (match the project's folder layout):
`import { createPenGraphNext } from "@pengraph/node/next"; export const blog = createPenGraphNext({ siteUrl: "<site address>", basePath: "<article path>" });`
It reads PENGRAPH_SITE_ID and PENGRAPH_API_KEY from the environment.
b. In the article page (e.g. `app/blog/[slug]/page.tsx`):
- replace `getPostBySlug` with `const found = await blog.getPost(slug); if (found?.moved) permanentRedirect(found.moved.to); if (!found) notFound(); const { post } = found;` (`permanentRedirect` and `notFound` from "next/navigation")
- replace any hand-written generateMetadata with `export const generateMetadata = ({ params }) => blog.generateMetadata(params);` unless it adds site-specific fields; if it does, merge them in
- render structured data: `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(blog.jsonLd(post)) }} />` (replace any existing schema_json script so it isn't duplicated)
c. Sitemap: if `app/sitemap.ts` exists, add `...(await blog.sitemap())` to what it returns; otherwise create it with `export default () => blog.sitemap([{ url: "<site address>" }]);`
d. llms.txt: create or replace `app/llms.txt/route.ts` with `export const GET = blog.llmsTxtRoute();`
e. In `next.config` add (merging with any existing redirects/rewrites rather than replacing them):
`redirects: async () => [...(existing redirects), ...(await blog.redirects())]`
`rewrites: async () => [...(existing rewrites), ...(await blog.rewrites())]`
next.config can't use path aliases like "@/"; import blog with a relative path.
f. Links to articles in list pages: use `blog.url(post)`.
5. If it is not Next.js (Express, Astro, Remix, plain Node…), use the plain client instead:
- `client.resolvePost(slug)` → `{ post }`, `{ moved: { to, statusCode } }` or `null`; on `moved`, send a 301 to `moved.to`; on `null`, 404
- `client.getRedirects()` → apply each `{ from, to, statusCode }` as a redirect
- `client.getIndexNowKey()` → serve it as `text/plain` at `/{key}.txt`
- sitemap from `sitemapEntries(await client.getAllPosts(), { siteUrl, basePath })`
- page head from `postMetadata(post, { siteUrl, basePath })` and `postJsonLd(post, { siteUrl, basePath })`
(all exported from "@pengraph/node")
6. Don't change how articles look, and don't touch unrelated code.
7. Check: the project builds and type-checks; an article page still renders; a made-up slug still shows the 404 page; /sitemap.xml lists the articles; /llms.txt answers.
8. Tell me what you changed, file by file, and anything you couldn't do.
Option 2: do it by hand
- Run
npm install @pengraph/node@^0.3.0. - Next.js: follow the five files in Use it in Next.js, keeping any redirects, rewrites or sitemap entries you already have.
- Other frameworks: use
client.resolvePost(slug)in your article route (send a 301 when it returnsmoved), applyclient.getRedirects(), and serveclient.getIndexNowKey()at/{key}.txt.
After upgrading
- Deploy the site as you normally do.
- Open an article on the live site. It should look the same as before.
- Open
/sitemap.xml. Every article should be listed with a date. - Open
/llms.txt. You should see a short description of your site. - Check a moved article, if Clara has merged or retired any: in SEOcondo open Website health → Redirects, copy an old address, and open it on your site. It should land on the new article.
- Redeploy whenever Clara adds redirects for addresses that aren't articles. Article addresses forward straight away without a redeploy.
If something fails, check that PENGRAPH_SITE_ID and PENGRAPH_API_KEY are set on the live server
(not only on your computer), then see What you get back.
What you get back
getPosts({ limit, offset, since }) returns { posts, pagination }. The server default limit is 50, the maximum is 100, and posts are newest-published first. pagination.total counts every matching published post, not just this page. since is an ISO 8601 timestamp; only posts with updated_at at or after that time are returned.
content is Markdown. content_html is that Markdown already rendered to HTML. Put content_html on the page. For the browser title and link previews, prefer meta_title / og_title and fall back to title, and prefer meta_description / og_description and fall back to excerpt. og_image_url is an absolute image URL or null.
If you syndicate posts onto another domain, set <link rel="canonical" href={post.canonical_url} /> so the original keeps credit and your copy is treated as an alternate. canonical_url is null only when a post has no resolvable publish location.
Every field is listed in the package README and in the HTTP API.
author is the byline (name, job_title, profile_url, …) or null when the site has no author profiles; schema_json is ready-made JSON-LD to put in a <script type="application/ld+json"> tag.
llms.txt and Organization data
client.getLlmsTxt() returns a ready-made llms.txt for AI assistants; serve it at /llms.txt:
// app/llms.txt/route.ts
import { blog } from "@/lib/blog";
export const GET = blog.llmsTxtRoute();
client.getOrganizationSchema() returns Organization (or LocalBusiness) JSON-LD for the home page:
const org = await blog.client.getOrganizationSchema();
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(org) }} />
Both need @pengraph/node 0.2.0 or later.
Example app
A minimal Next.js blog is in examples/next-blog in the SEOcondo repository.