Consume content in a Next.js site
Two ways to get Inkform content onto a site. Pick based on where the content lives relative to the site.
| Render files directly | Fetch over the API | |
|---|---|---|
| Content is in | the same repo as the site | a different repo, or shared by several sites |
| Needs an API key | no | only for private data |
| Runtime dependency on Inkform | none | yes, at build or request time |
| Best for | one site, one repo | many sites, one content source |
Option A — render the MDX directly
If the MDX is committed into the site's own repo, skip the API. Read the files at build time with the framework:
npm install @inkform/framework// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@inkform/framework'], // the framework ships raw TS
};
export default nextConfig;// app/blog/page.tsx
import Link from 'next/link';
import { loadBlogPosts } from '@inkform/framework/content';
import '@inkform/framework/styles.css';
export default async function BlogIndex() {
const posts = await loadBlogPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
<time dateTime={post.date}>{post.date}</time>
</li>
))}
</ul>
);
}// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Mdx } from '@inkform/framework/mdx';
import { loadBlogPost } from '@inkform/framework/content';
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await loadBlogPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<Mdx source={post.body} />
</article>
);
}Nothing here talks to the platform. The site builds and deploys even if Inkform is down, and works with no account at all.
@inkform/framework peer-depends on Next 16 or newer. On Next 15 or older, use
Option B, which has no framework dependency.
Option B — fetch over the API
Use this when the content lives elsewhere. Reads are public, so a key is optional — but send one anyway in production so your traffic is attributable and rate-limited predictably. Use a publishable key if the call can reach a browser.
# .env.local
NEXT_PUBLIC_INKFORM_PROJECT_ID=your-project-uuid
INKFORM_API_KEY=inkform_pk_your_publishable_key// lib/inkform.ts
const BASE = 'https://api.inkform.dev/api/v1';
const projectId = process.env.NEXT_PUBLIC_INKFORM_PROJECT_ID!;
export type BlogPostMeta = {
slug: string;
title: string;
date: string;
author: string | null;
tags: string[];
series: string | null;
description: string | null;
coverImage: string | null;
excerpt: string;
readTimeMinutes: number;
};
export type BlogPost = BlogPostMeta & { body: string };
async function get<T>(path: string, revalidate = 3600): Promise<T> {
const url = `${BASE}${path}${path.includes('?') ? '&' : '?'}projectId=${projectId}`;
const res = await fetch(url, {
headers: process.env.INKFORM_API_KEY
? { Authorization: `Bearer ${process.env.INKFORM_API_KEY}` }
: {},
next: { revalidate },
});
if (!res.ok) throw new Error(`Inkform ${res.status}: ${await res.text()}`);
return res.json();
}
export async function listPosts(limit = 20, offset = 0) {
const body = await get<{ data: BlogPostMeta[]; totalCount: number }>(
`/blog/posts?limit=${limit}&offset=${offset}`,
);
return body;
}
export async function getPost(slug: string) {
try {
const body = await get<{ data: BlogPost }>(`/blog/posts/${slug}`);
return body.data;
} catch {
return null; // 404 — unknown or unpublished slug
}
}// app/blog/page.tsx
import Link from 'next/link';
import { listPosts } from '@/lib/inkform';
export default async function BlogIndex() {
const { data: posts, totalCount } = await listPosts();
return (
<>
<p>{totalCount} posts</p>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
<span>{post.readTimeMinutes} min read</span>
</li>
))}
</ul>
</>
);
}Rendering the body
body is MDX with frontmatter stripped. If the content is plain markdown, any renderer
works. If it uses MDX components, render with the framework's Mdx, or with
next-mdx-remote if you'd rather not add the framework:
import { MDXRemote } from 'next-mdx-remote/rsc';
<MDXRemote source={post.body} />;Only render MDX from a source you control. MDX executes as code — treating a third party's MDX as data is a remote-code-execution bug.
Static generation
Pre-render every post at build time:
export async function generateStaticParams() {
const { data } = await listPosts(100);
return data.map((post) => ({ slug: post.slug }));
}Past 100 posts, page through — see Pagination.
Caching
Published content changes when you publish, not per visitor. Don't fetch per request.
- Build-time (
generateStaticParams+ default caching) — fastest, needs a rebuild to reflect edits. - Revalidate on a timer (
next: { revalidate: 3600 }) — good default. - On-demand revalidation — call
revalidatePath()from a webhook when you publish, if you need edits live immediately.
Comments and reactions are the exception: they change between publishes, so use a short window like 60 seconds.
Adding a signup form
Content and newsletter are independent — you can fetch posts with Option B and still use the framework's form, or neither. See Add newsletter signup to a site.
Troubleshooting
| Symptom | Cause |
|---|---|
400 projectId query parameter is required | No projectId and no key on the request |
Empty data with totalCount: 0 | Wrong project ID, or nothing published yet — drafts are never served |
404 on a post you can see in the dashboard | Its status is draft or scheduled |
429 | Too many requests — cache, and see Rate limits |
Redirected to app.inkform.dev | You called the apex instead of api.inkform.dev |