Rendering Database Data in Next.js Server Components
Step 8 of 31 — Next.js Tutorial Series | Source code for this step
Commands in This Step
| Command | Purpose |
|---|---|
npx prisma db seed | Re-seed database (troubleshooting) |
What You Will Build
By the end of this step, your /posts page will display real blog posts fetched from the database — including each post's title, content, author name, and creation date. No more placeholder text.
Goal: Make app/posts/page.tsx an async Server Component that queries Prisma and renders the results.
Table of Contents
- The Current State
- Core Concept:
findMany() - Core Concept:
includeand Relations - Core Concept:
orderBy - Update the Posts Page
- Understanding the Code
- Verify the Result
- Summary & Key Takeaways
The Current State
Open app/posts/page.tsx. It currently looks like this:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'All Posts — Superblog',
description: 'Browse all blog posts on Superblog',
}
export default function PostsPage() {
return (
<>
<h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>
<p className="mb-8 text-gray-600">
Posts will appear here once we connect to the database.
</p>
<div className="rounded-lg bg-white p-6 shadow-md">
<p className="italic text-gray-400">
No posts yet. Check back after Step 8!
</p>
</div>
</>
)
}
This is a regular (synchronous) Server Component with hardcoded placeholder content. By the end of this step, we will replace it with a real database query.
Core Concept: findMany()
findMany() is the Prisma method for fetching multiple records from a table. It returns an array — always. If there are no matching records, it returns an empty array [], never null.
const posts = await prisma.post.findMany()
// → Post[]
findMany() accepts an options object with several useful keys:
| Option | Purpose | Example |
|---|---|---|
include | Join related tables and include their data | include: { author: true } |
select | Choose which fields to return | select: { title: true, id: true } |
where | Filter results | where: { published: true } |
orderBy | Sort results | orderBy: { createdAt: 'desc' } |
take | Limit the number of results (like SQL LIMIT) | take: 10 |
skip | Skip N records (like SQL OFFSET, for pagination) | skip: 10 |
We will use include and orderBy in this step, and revisit where, take, and skip in the pagination step.
Core Concept: include and Relations
In Step 5, we defined a relation between Post and User:
model Post {
author User @relation(fields: [authorId], references: [id])
authorId Int
}
In SQL, to get the author's name alongside each post you would write a JOIN. With Prisma, you use include:
const posts = await prisma.post.findMany({
include: { author: true },
})
This tells Prisma: for each Post, also fetch its related User record and attach it as post.author.
The result type changes from Post[] to (Post & { author: User })[].
Let's break this TypeScript syntax down piece by piece:
Post— the base type generated by Prisma. It has fields likeid,title,content,createdAt, etc.{ author: User }— an object type that has a single propertyauthorof typeUser.Post & { author: User }— the&is called an intersection type. It means "a type that has all the properties ofPostand also anauthorproperty of typeUser." Think of&as merging two types together.(Post & { author: User })[]— the[]at the end means "an array of" that merged type.
So the full type means: an array where each element has every field from Post plus an author field containing a full User object.
TypeScript figures this out automatically from the include option — no type annotations needed.
posts[0].title // ✅ string
posts[0].author?.name // ✅ string | undefined — joined from the User table (name is optional)
posts[0].author.email // ✅ string — joined from the User table
Core Concept: orderBy
By default, findMany() returns records in insertion order. To sort by newest first:
orderBy: {
createdAt: 'desc'
}
'desc'— newest first (descending)'asc'— oldest first (ascending)
Update the Posts Page
Replace the entire contents of app/posts/page.tsx:
import type { Metadata } from 'next'
import prisma from '@/lib/prisma'
export const metadata: Metadata = {
title: 'All Posts — Superblog',
description: 'Browse all blog posts on Superblog',
}
export default async function PostsPage() {
const posts = await prisma.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
})
return (
<>
<h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>
<p className="mb-8 text-gray-600">{posts.length} posts</p>
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="rounded-lg bg-white p-6 shadow-md">
<h3 className="mb-1 text-xl font-semibold text-gray-800">
{post.title}
</h3>
<p className="mb-3 text-sm text-gray-500">
By {post.author?.name} ·{' '}
{new Date(post.createdAt).toLocaleDateString()}
</p>
<p className="text-gray-700">{post.content}</p>
</div>
))}
</div>
</>
)
}
Understanding the Code
The import
import prisma from '@/lib/prisma'
The singleton we created in Step 7. All database access in the app goes through this one shared client.
Making the component async
export default async function PostsPage() {
Same pattern as in Step 7 — declaring the component async allows us to await database queries directly in the component body. Next.js handles the rendering server-side.
The query
const posts = await prisma.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
})
This runs a single SQL query that joins posts and users, returning all posts sorted newest-first. Prisma translates this into:
SELECT posts.*, users.*
FROM posts
JOIN users ON posts."authorId" = users.id
ORDER BY posts."createdAt" DESC
Rendering the list
{posts.map((post) => (
<div key={post.id} ...>
Standard React list rendering. post.id is the unique key. Because include: { author: true } was used, TypeScript knows post.author exists — there is no extra type work needed. We use post.author?.name since name is optional in our User model.
The date
new Date(post.createdAt).toLocaleDateString()
post.createdAt is a Date object (Prisma converts the database timestamp automatically). toLocaleDateString() formats it in the browser's locale (e.g., 3/12/2026).
The · separator
By {post.author?.name} ·{' '}
We use post.author?.name (optional chaining) because the name field is optional in the User model — it could be null. The ?. safely returns undefined instead of crashing if name is missing.
· is an HTML entity for a centered dot · used as a visual separator. The {' '} adds a space after it.
Verify the Result
Make sure your dev server is running:
pnpm dev
Navigate to http://localhost:3000/posts. You should see 14 blog posts listed, each showing:
- Post title
- Author name and date
- Post content
They appear newest-first because of orderBy: { createdAt: 'desc' }.
Seeing "Cannot read properties of null (reading 'name')"? This means a post in your database has no author (
authorIdis null or the author was deleted). Re-runnpx prisma db seedto restore clean seed data.
Summary & Key Takeaways
| Concept | What it means |
|---|---|
findMany() | Returns an array of all matching records; empty array if none |
include | Joins related models and attaches them to each result |
orderBy | Sorts results; 'desc' = newest first, 'asc' = oldest first |
| Async Server Component | A React component that can await queries before rendering |
| Type inference | Prisma infers the correct TypeScript type for relations automatically |
What is Next
In Step 9, we take a deeper look at Prisma's query system — include, select, nested relations, filtering, and performance strategies — with side-by-side comparisons to Laravel Eloquent.