Rendering Database Data in Next.js Server Components

Step 8 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


Commands in This Step

CommandPurpose
npx prisma db seedRe-seed database (troubleshooting)

What You Will Build

↑ Index

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

  1. The Current State
  2. Core Concept: findMany()
  3. Core Concept: include and Relations
  4. Core Concept: orderBy
  5. Update the Posts Page
  6. Understanding the Code
  7. Verify the Result
  8. Summary & Key Takeaways

The Current State

↑ Index

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()

↑ Index

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:

OptionPurposeExample
includeJoin related tables and include their datainclude: { author: true }
selectChoose which fields to returnselect: { title: true, id: true }
whereFilter resultswhere: { published: true }
orderBySort resultsorderBy: { createdAt: 'desc' }
takeLimit the number of results (like SQL LIMIT)take: 10
skipSkip 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

↑ Index

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 like id, title, content, createdAt, etc.
  • { author: User } — an object type that has a single property author of type User.
  • Post & { author: User } — the & is called an intersection type. It means "a type that has all the properties of Post and also an author property of type User." 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

↑ Index

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

↑ Index

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} &middot;{' '}
              {new Date(post.createdAt).toLocaleDateString()}
            </p>
            <p className="text-gray-700">{post.content}</p>
          </div>
        ))}
      </div>
    </>
  )
}

Understanding the Code

↑ Index

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 &middot; separator

By {post.author?.name} &middot;{' '}

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.

&middot; is an HTML entity for a centered dot · used as a visual separator. The {' '} adds a space after it.


Verify the Result

↑ Index

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 (authorId is null or the author was deleted). Re-run npx prisma db seed to restore clean seed data.


Summary & Key Takeaways

↑ Index

ConceptWhat it means
findMany()Returns an array of all matching records; empty array if none
includeJoins related models and attaches them to each result
orderBySorts results; 'desc' = newest first, 'asc' = oldest first
Async Server ComponentA React component that can await queries before rendering
Type inferencePrisma infers the correct TypeScript type for relations automatically

What is Next

In Step 9, we take a deeper look at Prisma's query systeminclude, select, nested relations, filtering, and performance strategies — with side-by-side comparisons to Laravel Eloquent.