Posts and Authors: Linking Content in Next.js
This article demonstrates how to connect different content types in a Next.js application—specifically, a blog with multiple authors. Each post will be attributed to an author, displaying their name and picture alongside the post content. Each author gets a profile page listing all their posts, which requires establishing relationships between the two content types. The same pattern can apply to linking courses and lessons, or actors and movies.
All content lives on the local filesystem, but in different file formats: posts as Markdown (since they are text-heavy and benefit from easy editing) and authors as JSON (since they hold simpler data). Helper functions will read and combine these file types. Next.js provides features that make this straightforward—dynamic routing, next/link for navigation, and built-in image optimization via next/image—so you can focus on application logic rather than setup.
Basic familiarity with Next.js is assumed, particularly around page creation and data fetching. Styling is intentionally omitted from this project; you can find a complete starter with sample posts and a stylesheet on GitHub. A replacement pages/_app.js with navigation is also available there if you want the same frame as shown here.
Project Setup
Start with a fresh project using create-next-app and move into its directory:
$ npx create-next-app multiauthor-blog
$ cd multiauthor-blog
Since we will read Markdown files, install additional parsing dependencies:
multiauthor-blog$ yarn add gray-matter remark remark-html
Launch the development server to view the default page:
multiauthor-blog$ yarn dev
Add navigation links in pages/_app.js before the destination pages exist, then fill those pages in as you go.
import Link from 'next/link'
import '../styles/globals.css'
export default function App({ Component, pageProps }) {
return (
<>
<header>
<nav>
<ul>
<li>
<Link href="/">
<a>Home</a>
</Link>
</li>
<li>
<Link href="https://www.smashingmagazine.com/posts">
<a>Posts</a>
</Link>
</li>
<li>
<Link href="https://www.smashingmagazine.com/authors">
<a>Authors</a>
</Link>
</li>
</ul>
</nav>
</header>
<main>
<Component {...pageProps} />
</main>
</>
)
}
Post Files and Frontmatter
Store posts in a _posts/ directory, separate from code. Each post is a Markdown file whose filename determines its route slug—_posts/hello-world.md becomes accessible at /posts/hello-world. Metadata such as the title and a short excerpt lives in the file's frontmatter:
---
title: "Hello World!"
excerpt: "This is my first blog post."
createdAt: "2021-05-03"
---
Hey, how are you doing? Welcome to my blog. In this post, …
Add several more posts so the blog is populated. You can author your own or use the sample files from the repository linked above.
Listing Posts with a Data Helper
Create pages/posts/index.js as the post listing, which will be reachable at /posts. The page component starts with a simple heading:
export default function Posts() {
return (
<div className="posts">
<h1>Posts</h1>
{/* TODO: render posts */}
</div>
)
}
To display real content, use Next.js's getStaticProps() to pass data into the component as props. A first throwaway version passes hardcoded placeholder posts to define the expected format—title, excerpt, permalink, and date—also without requiring any file-reading code in the component. The page renders these fields with a link to each post:
+import Link from 'next/link'
-export default function Posts() {
+export default function Posts({ posts }) {
return (
<div className="posts">
<h1>Posts</h1>
- {/* TODO: render posts */}
+ {posts.map(post => {
+ const prettyDate = new Date(post.createdAt).toLocaleString('en-US', {
+ month: 'short',
+ day: '2-digit',
+ year: 'numeric',
+ })
+
+ return (
+ <article key={post.slug}>
+ <h2>
+ <Link href={post.permalink}>
+ <a>{post.title}</a>
+ </Link>
+ </h2>
+
+ <time dateTime={post.createdAt}>{prettyDate}</time>
+
+ <p>{post.excerpt}</p>
+
+ <Link href={post.permalink}>
+ <a>Read more →</a>
+ </Link>
+ </article>
+ )
+ })}
</div>
)
}
export function getStaticProps() { … }
Hardcoding is only temporary. Reading can be done directly within getStaticProps() using Node.js fs modules, but for better project organization we move that logic into a separate helper file. Convention places these functions in lib/api.js, keeping page components focused on display. The helper function getAllPosts() constructs the path to _posts/ with path.join(), lists filenames with fs.readdirSync(), then reads each file. Contents are processed by gray-matter to strip frontmatter from the Markdown body; the slug comes from stripping the .md extension. Since the listing page only needs metadata, the body can be ignored:
import fs from 'fs'
import path from 'path'
+import matter from 'gray-matter'
export function getAllPosts() {
const postsDirectory = path.join(process.cwd(), '_posts')
const filenames = fs.readdirSync(postsDirectory)
return filenames.map(filename => {
const file = fs.readFileSync(path.join(process.cwd(), '_posts', filename), 'utf8')
- // TODO: transform and return file
+ // get frontmatter
+ const { data } = matter(file)
+
+ // get slug from filename
+ const slug = filename.replace(/\.md$/, '')
+
+ // return combined frontmatter and slug; build permalink
+ return {
+ ...data,
+ slug,
+ permalink: `/posts/${slug}`,
+ }
})
}
Spreading ...data into the returned object lets you access frontmatter values as {post.title} instead of {post.data.title}. Replace the placeholder code in getStaticProps() with this function to see your real posts appear in the browser.
+import { getAllPosts } from '../../lib/api'
export default function Posts({ posts }) { … }
export function getStaticProps() {
return {
props: {
- posts: [
- {
- title: "My first post",
- createdAt: "2021-05-01",
- excerpt: "A short excerpt summarizing the post.",
- permalink: "/posts/my-first-post",
- slug: "my-first-post",
- }, {
- title: "My second post",
- createdAt: "2021-05-04",
- excerpt: "Another summary that is short.",
- permalink: "/posts/my-second-post",
- slug: "my-second-post",
- }
- ]
+ posts: getAllPosts(),
}
}
}
Single Post Pages and Rendering Markdown
Clicking a listing link currently leads nowhere. Dynamic routing solves this: a file named pages/posts/[slug].js matches any URL like /posts/abc, with the segment value available to the page as params.slug inside getStaticProps().
A corresponding helper getPostBySlug(slug) retrieves a single post, and unlike the listing function, must also transform the Markdown body into render-ready HTML using remark:
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
+import remark from 'remark'
+import html from 'remark-html'
export function getAllPosts() { … }
+export function getPostBySlug(slug) {
+ const file = fs.readFileSync(path.join(process.cwd(), '_posts', `${slug}.md`), 'utf8')
+
+ const {
+ content,
+ data,
+ } = matter(file)
+
+ const body = remark().use(html).processSync(content).toString()
+
+ return {
+ ...data,
+ body,
+ }
+}
This function reads the file matching the slug directly, avoiding an unnecessary full-directory scan with getAllPosts(). Since the two helpers serve different purposes, keeping them separate is cleaner than forcing getAllPosts() to also handle bodies it otherwise ignores.
For the page's getStaticProps(), fetch the specific post by slug:
import { getPostBySlug } from '../../lib/api'
export default function Post({ post }) {
const prettyDate = new Date(post.createdAt).toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
})
return (
<div className="post">
<h1>{post.title}</h1>
<time dateTime={post.createdAt}>{prettyDate}</time>
{/* TODO: render body */}
</div>
)
}
export function getStaticProps({ params }) {
return {
props: {
post: getPostBySlug(params.slug),
},
}
}
Pages with dynamic routes must also export getStaticPaths(), which tells Next.js which path values to generate based on existing posts:
-import { getPostBySlug } from '../../lib/api'
+import { getAllPosts, getPostBySlug } from '../../lib/api'
export default function Post({ post }) { … }
export function getStaticProps({ params }) { … }
+export function getStaticPaths() {
+ return {
+ fallback: false,
+ paths: getAllPosts().map(post => ({
+ params: {
+ slug: post.slug,
+ },
+ })),
+ }
+}
The Markdown body has already been parsed into HTML, so the page can render it directly with dangerouslySetInnerHTML. This is safe here since you fully control the post content and are unlikely to inject unsafe scripts, despite the API's name:
import { getAllPosts, getPostBySlug } from '../../lib/api'
export default function Post({ post }) {
const prettyDate = new Date(post.createdAt).toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
})
return (
<div className="post">
<h1>{post.title}</h1>
<time dateTime={post.createdAt}>{prettyDate}</time>
- {/* TODO: render body */}
+ <div dangerouslySetInnerHTML={{ __html: post.body }} />
</div>
)
}
export function getStaticProps({ params }) { … }
export function getStaticPaths() { … }
Following a link from the overview now lands on the individual post page.
Author Content With JSON
Posts are in place, so authors get the same treatment — but this time stored as JSON rather than Markdown. Mixing file formats in one project is fine; the helper functions isolate callers from the underlying storage. A page component never has to know whether content lives in _posts/ or _authors/.
Create _authors/ and drop in files named by each author's slug. Each file holds a JSON object with the author's full name.
{
"name": "Adrian Webber"
}
Two authors are enough for now. To add character, place a profile picture for each author in public/, also named by slug. The filename convention links picture to person without a path field in the JSON — the objects only carry data code can't infer.
multi-author-blog/
├─ _authors/
│ ├─ adrian-webber.json
│ └─ megan-carter.json
├─ _posts/
│ └─ …
├─ pages/
│ └─ …
└─ public/
├─ adrian-webber.jpg
└─ megan-carter.jpg
The post helpers have counterparts in lib/api.js: getAllAuthors() and getAuthorBySlug(slug). They mirror the post functions but skip remark and gray-matter; plain JSON.parse() turns file contents into objects.
const contents = fs.readFileSync(somePath, 'utf8')
// ⇒ looks like an object, but is a string
// e.g. '{ "name": "John Doe" }'
const json = JSON.parse(contents)
// ⇒ a real JavaScript object we can do things with
// e.g. { name: "John Doe" }
export function getAllPosts() { … }
export function getPostBySlug(slug) { … }
+export function getAllAuthors() {
+ const authorsDirectory = path.join(process.cwd(), '_authors')
+ const filenames = fs.readdirSync(authorsDirectory)
+
+ return filenames.map(filename => {
+ const file = fs.readFileSync(path.join(process.cwd(), '_authors', filename), 'utf8')
+
+ // get data
+ const data = JSON.parse(file)
+
+ // get slug from filename
+ const slug = filename.replace(/\.json/, '')
+
+ // return combined frontmatter and slug; build permalink
+ return {
+ ...data,
+ slug,
+ permalink: `/authors/${slug}`,
+ profilePictureUrl: `${slug}.jpg`,
+ }
+ })
+}
+
+export function getAuthorBySlug(slug) {
+ const file = fs.readFileSync(path.join(process.cwd(), '_authors', `${slug}.json`), 'utf8')
+
+ const data = JSON.parse(file)
+
+ return {
+ ...data,
+ permalink: `/authors/${slug}`,
+ profilePictureUrl: `/${slug}.jpg`,
+ slug,
+ }
+}
Author Pages
A new page at pages/authors/index.js yields /authors. The component calls getAllAuthors() in getStaticProps() and maps over the results, displaying names and pictures. The helper function handles the filesystem work, so the page stays format-agnostic.
import Image from 'next/image'
import Link from 'next/link'
import { getAllAuthors } from '../../lib/api/authors'
export default function Authors({ authors }) {
return (
<div className="authors">
<h1>Authors</h1>
{authors.map(author => (
<div key={author.slug}>
<h2>
<Link href={author.permalink}>
<a>{author.name}</a>
</Link>
</h2>
<Image alt={author.name} src={author.profilePictureUrl} height="40" width="40" />
<Link href={author.permalink}>
<a>Go to profile →</a>
</Link>
</div>
))}
</div>
)
}
export function getStaticProps() {
return {
props: {
authors: getAllAuthors(),
},
}
}
Profile pages come from pages/authors/[slug].js. Since authors have no body text, the profiles show only name and image. A dedicated getStaticPaths() tells Next.js which slugs to pre-render.
import Image from 'next/image'
import { getAllAuthors, getAuthorBySlug } from '../../lib/api'
export default function Author({ author }) {
return (
<div className="author">
<h1>{author.name}</h1>
<Image alt={author.name} src={author.profilePictureUrl} height="80" width="80" />
</div>
)
}
export function getStaticProps({ params }) {
return {
props: {
author: getAuthorBySlug(params.slug),
},
}
}
export function getStaticPaths() {
return {
fallback: false,
paths: getAllAuthors().map(author => ({
params: {
slug: author.slug,
},
})),
}
}
Tying Posts To Authors
Slugs connect the two content types. Rather than cross-referencing in both directions, add an author slug to each post's frontmatter — one directional link is enough.
---
title: "Hello World!"
excerpt: "This is my first blog post."
createdAt: "2021-05-03"
+author: adrian-webber
---
Hey, how are you doing? Welcome to my blog. In this post, …
That field arrives as a plain string after gray-matter processing.
const post = getPostBySlug("hello-world")
const author = post.author
console.log(author)
// "adrian-webber"
Resolve the string to an object with getAuthorBySlug(slug).
const post = getPostBySlug("hello-world")
-const author = post.author
+const author = getAuthorBySlug(post.author)
console.log(author)
// {
// name: "Adrian Webber",
// slug: "adrian-webber",
// profilePictureUrl: "/adrian-webber.jpg",
// permalink: "/authors/adrian-webber"
// }
On a single post page, call it once inside getStaticProps(). Spreading ...post into a new object and placing author after the spread overwrites the string with the full author object, so the component can use post.author.name.
+import Image from 'next/image'
+import Link from 'next/link'
-import { getPostBySlug } from '../../lib/api'
+import { getAuthorBySlug, getPostBySlug } from '../../lib/api'
export default function Post({ post }) {
const prettyDate = new Date(post.createdAt).toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
})
return (
<div className="post">
<h1>{post.title}</h1>
<time dateTime={post.createdAt}>{prettyDate}</time>
+ <div>
+ <Image alt={post.author.name} src={post.author.profilePictureUrl} height="40" width="40" />
+
+ <Link href={post.author.permalink}>
+ <a>
+ {post.author.name}
+ </a>
+ </Link>
+ </div>
<div dangerouslySetInnerHTML={{ __html: post.body }}>
</div>
)
}
export function getStaticProps({ params }) {
+ const post = getPostBySlug(params.slug)
return {
props: {
- post: getPostBySlug(params.slug),
+ post: {
+ ...post,
+ author: getAuthorBySlug(post.author),
+ },
},
}
}
The post overview page needs the same upgrade, but iterates over every post and resolves each author individually.
+import Image from 'next/image'
+import Link from 'next/link'
-import { getAllPosts } from '../../lib/api'
+import { getAllPosts, getAuthorBySlug } from '../../lib/api'
export default function Posts({ posts }) {
return (
<div className="posts">
<h1>Posts</h1>
{posts.map(post => {
const prettyDate = new Date(post.createdAt).toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
})
return (
<article key={post.slug}>
<h2>
<Link href={post.permalink}>
<a>{post.title}</a>
</Link>
</h2>
<time dateTime={post.createdAt}>{prettyDate}</time>
+ <div>
+ <Image alt={post.author.name} src={post.author.profilePictureUrl} height="40" width="40" />
+
+ <span>{post.author.name}</span>
+ </div>
<p>{post.excerpt}</p>
<Link href={post.permalink}>
<a>Read more →</a>
</Link>
</article>
)
})}
</div>
)
}
export function getStaticProps() {
return {
props: {
- posts: getAllPosts(),
+ posts: getAllPosts().map(post => ({
+ ...post,
+ author: getAuthorBySlug(post.author),
+ })),
}
}
}
Author profiles don't need a post list in their JSON. Fetch getAllPosts() on the profile page and filter for posts whose author slug matches.
import Image from 'next/image'
+import Link from 'next/link'
-import { getAllAuthors, getAuthorBySlug } from '../../lib/api'
+import { getAllAuthors, getAllPosts, getAuthorBySlug } from '../../lib/api'
export default function Author({ author }) {
return (
<div className="author">
<h1>{author.name}</h1>
<Image alt={author.name} src={author.profilePictureUrl} height="40" width="40" />
+ <h2>Posts</h2>
+
+ <ul>
+ {author.posts.map(post => (
+ <li>
+ <Link href={post.permalink}>
+ <a>
+ {post.title}
+ </a>
+ </Link>
+ </li>
+ ))}
+ </ul>
</div>
)
}
export function getStaticProps({ params }) {
const author = getAuthorBySlug(params.slug)
return {
props: {
- author: getAuthorBySlug(params.slug),
+ author: {
+ ...author,
+ posts: getAllPosts().filter(post => post.author === author.slug),
+ },
},
}
}
export function getStaticPaths() { … }
The author index page stays clean, showing only a per-author post count.
import Image from 'next/image'
import Link from 'next/link'
-import { getAllAuthors } from '../../lib/api'
+import { getAllAuthors, getAllPosts } from '../../lib/api'
export default function Authors({ authors }) {
return (
<div className="authors">
<h1>Authors</h1>
{authors.map(author => (
<div key={author.slug}>
<h2>
<Link href={author.permalink}>
<a>
{author.name}
</a>
</Link>
</h2>
<Image alt={author.name} src={author.profilePictureUrl} height="40" width="40" />
+ <p>{author.posts.length} post(s)</p>
<Link href={author.permalink}>
<a>Go to profile →</a>
</Link>
</div>
))}
</div>
)
}
export function getStaticProps() {
return {
props: {
- authors: getAllAuthors(),
+ authors: getAllAuthors().map(author => ({
+ ...author,
+ posts: getAllPosts().filter(post => post.author === author.slug),
+ })),
}
}
}
Extending the Pattern
The post-to-author link opens up relational possibilities. A reviewer field in frontmatter pulls another author from _authors/ via the same lookup.
---
title: "Hello World!"
excerpt: "This is my first blog post."
createdAt: "2021-05-03"
author: adrian-webber
+reviewer: megan-carter
---
Hey, how are you doing? Welcome to my blog. In this post, …
export function getStaticProps({ params }) {
const post = getPostBySlug(params.slug)
return {
props: {
post: {
...post,
author: getAuthorBySlug(post.author),
+ reviewer: getAuthorBySlug(post.reviewer),
},
},
}
}
Co-authors work by listing multiple slugs in the field. getStaticProps() would then map over the array instead of resolving a single value.
---
title: "Hello World!"
excerpt: "This is my first blog post."
createdAt: "2021-05-03"
-author: adrian-webber
+authors:
+ - adrian-webber
+ - megan-carter
---
Hey, how are you doing? Welcome to my blog. In this post, …
export function getStaticProps({ params }) {
const post = getPostBySlug(params.slug)
return {
props: {
post: {
...post,
- author: getAuthorBySlug(post.author),
+ authors: post.authors.map(getAuthorBySlug),
},
},
}
}
One-to-one, one-to-many, and many-to-many relationships all fit this model — newsletters, case studies, characters in a movie franchise, players on a team. Because helpers abstract the source, content can come from a filesystem, an API, or anywhere else and still merge cleanly.
Related Reading
- Next.js Data Fetching docs — background on
getStaticProps()andgetStaticPaths(). - Building a CSS Tricks Website Clone with Strapi and Next.js — swap the local filesystem for a Strapi backend.
- Comparing Styling Methods in Next.js — alternate ways to handle custom CSS.
- Markdown/MDX with Next.js — bring JSX and React components into Markdown.
Further Reading
- How To Build Server-Side Rendered (SSR) Svelte Apps With SvelteKit
- Full Stack GraphQL With Next.js, Neo4j AuraDB And Vercel
- How To Monitor And Optimize Google Core Web Vitals
- The Fight For The Main Thread




