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 directlyFetch over the API
Content is inthe same repo as the sitea different repo, or shared by several sites
Needs an API keynoonly for private data
Runtime dependency on Inkformnoneyes, at build or request time
Best forone site, one repomany 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

SymptomCause
400 projectId query parameter is requiredNo projectId and no key on the request
Empty data with totalCount: 0Wrong project ID, or nothing published yet — drafts are never served
404 on a post you can see in the dashboardIts status is draft or scheduled
429Too many requests — cache, and see Rate limits
Redirected to app.inkform.devYou called the apex instead of api.inkform.dev