5 Common Pitfalls with Server Components in Next13 (with examples)

5 Common Pitfalls with Server Components in Next13 (with examples)

The App Router is a new feature in NextJS 13, and it was recently switched to being the recommended option when you create a new NextJS application. This effectively replaces the Pages Router, and NextJS has documentation on how you can incrementally switch over.

The most notable difference in the App Router is that all components, by default, are Server Components instead of Client Components.

Server Components have a few big differences from their client component equivalent, and with that comes many possible points of confusion. Let’s look at a few common problems:

1. Trying to use React Hooks in Server Components

Let’s start with a really simple example. I just want to fetch and display a cat fact. If you have used React before, this pattern of using useEffect to trigger an initial fetch is probably pretty familiar:

import {useEffect, useState} from "react";

export default function Demo() {
    const [catFact, setCatFact] = useState<string | null>(null)

    useEffect(() => {
        fetch("https://catfact.ninja/fact")
            .then(response => response.json())
            .then(catFactJson => setCatFact(catFactJson["fact"]))
    }, [])

    if (catFact) {
        return <div>{catFact}</div>
    } else {
        return <div>Loading...</div>
    }
}

We’ll place this under app/demo/page.tsx and visit localhost:3000/demo awaiting our cat fact, only to get:

a ReactServerComponentsError.

The problem is that we are not able to use hooks like useState or useEffect in server components, as they don’t allow for any interactivity.

Now notably, there are actually two ways to fix the issue:

Fix: Add “use client” to the top of the file

Adding "use client" basically enables us to fall back to the behavior you are familiar with, whether you are coming from the Pages Router or just a normal React background.

The component is rendered in the browser, fetches will happen in the browser, and you can see it in your network tab.

This should all sound exceedingly normal, other than needing to add "use client" to the top of our file.

Alternative Fix: Convert it to a server component

In some cases, the hooks we were using weren’t necessary. In this specific case, we were just fetching data once when the component mounted. We could rewrite this more succinctly as a server component:

export default async function Demo() {
    const catFactResponse = await fetch("https://catfact.ninja/fact")
    const catFactJson = await catFactResponse.json()
    return <div>{catFactJson["fact"]}</div>
}

1b - Using Event Handlers (like onClick) in Server Components

To add to the “server components don’t allow for interactivity” idea, this server component won’t work:

export default function Demo() {
    return <button onClick={() => console.log("hi")}>
        A normal button
    </button>
}

We go ahead and try to load this component, only to be met with:

“Error: Event handlers cannot be passed to Client Component props”

and

“If you need interactivity, consider converting part of this to a Client Component.”

Fix: Add “use client” for interactive components

Anything interactive (a handler for clicking a button, a hook like useEffect) can only be inside of a client component. In this case, our normal button needs “use client” up top. There isn’t a server equivalent.

2. Importing a server component directly into a client component

Let’s say we want to take our previous example where we fetch cat facts and change it so we get a new Cat Fact every time the user clicks a button. We want something like this:

We can separate out our cat fact server component and pass some value in to trigger a re-render:

type Props = {
    heartbeat: number
}

// Note: This is incorrect, don't do this
export default async function CatFact({heartbeat}: Props) {
    const catFactResponse = await fetch("https://catfact.ninja/fact")
    const catFactJson = await catFactResponse.json()
    return <div>{catFactJson["fact"]}</div>
}
"use client"

import {useState} from "react";
import CatFact from "@/components/CatFact";

export default function Demo() {
    const [heartbeat, setHeartbeat] = useState(0)

    return <div>
        <CatFact heartbeat={heartbeat}/>
        <button onClick={() => setHeartbeat(h => h + 1)}>New Fact Please!</button>
    </div>
}

Now if you go to the page, you will see a cat fact, but the button doesn’t appear to work? And the network console tells a very different story:

There’s a lot to unpack here, namely:

  • Why are requests being made from the client? I’m using server components, no?
  • Why are there so many requests being made, to the point where we are getting rate limited? (really sorry to the Cat Fact folks for the spam, I thought this was a helpful example)

There are a few problems here, but the biggest is that we cannot import server components into client components. The specific thing that’s wrong is this line of code:

import CatFact from "@/components/CatFact";

Our CatFact component is now being treated like a client component, which is why we’re seeing the fetches in the browser, and now we’ve effectively just written a really inefficient component that infinitely refetches cat facts.

Fix: Convert this to a client component

It’s natural when a new cool abstraction exists to want to use it as much as possible. That being said, it’s important to note that while server components are useful, you need to know when to use them and when you don’t need them.

In this case, we can just go back to good old regular React components and hooks:

"use client"

import {useEffect, useState} from "react";

export default function Demo() {
    const [catFact, setCatFact] = useState<string | null>(null)
    const [heartbeat, setHeartbeat] = useState(0)

    useEffect(() => {
        fetch("https://catfact.ninja/fact")
            .then(response => response.json())
            .then(catFactJson => setCatFact(catFactJson["fact"]))
    }, [heartbeat])

    if (catFact) {
        return <div>
            <div>{catFact}</div>
            <button onClick={() => setHeartbeat(h => h+1)}>New Fact Please!</button>
        </div>
    } else {
        return <div>Loading...</div>
    }
}

Alternative Fix: Convert it (carefully) to a server component

It is possible for a server component to be a child of a client component; the only thing we cannot do is import the server component into our client code.

What we need to do instead is tell Next.js that our client component will have some child; we just can’t directly reference the CatFact component.

Let’s make a new component for this, CatFactLoader:

"use client"

import React from "react";
import {useRouter} from "next/navigation";

type CatFactLoaderProps = {
    children: React.ReactNode
}

export default function CatFactLoader({children}: CatFactLoaderProps) {
    const router = useRouter()
    return <div>
        {children}
        <button onClick={() => router.refresh()}>New Fact Please!</button>
    </div>
}

Update CatFact, which will, unfortunately, cache the facts by default:

export default async function CatFact() {
    const catFactResponse = await fetch("https://catfact.ninja/fact", {
        // small note - next.js will cache the cat fact, which we can turn off
        cache: 'no-store'
    })
    const catFactJson = await catFactResponse.json()
    return <div>{catFactJson["fact"]}</div>
}

and update our Demo component with a Server component that composes the two:

import CatFactLoader from "@/components/CatFactLoader";
import CatFact from "@/components/CatFact";

export default function Demo() {
    return <CatFactLoader>
        <CatFact />
    </CatFactLoader>
}

And this works - however, it’s up to you to decide which pattern you like more.

Speaking of weird patterns, why did we need router.refresh() in the client…

3. Assuming Server Components Re-render

One of the first examples of React code that most people see is the counter example:

function MyButton() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      Clicked {count} times
    </button>
  );
}

And this displays an important concept - when the state is updated, the button re-renders.

Server components obviously don’t have state (see 1 up above), but you can use things like cookies:

export default function CookieRenderer() {
    const name = cookies().get("name")?.value
    
    if (name) {
        return <div>Hello, {name}!</div>
    } else {
        return <div>Hello, there!</div>
    }
}

You may think that when a new cookie is set, this component would re-render, but it doesn’t. This is maybe more obviously true if your server component makes calls to your database:

export default async function Demo() {
    const comments = db.select()
        .from(commentsTable)
        .orderBy(desc(commentsTable.createdAt))
        .limit(10)
        .all()

    return <div>
        {comments.map(comment => <Comment comment={comment} />)}
    </div>
}

This is notably something we, at PropelAuth, dealt with a lot as we provide functions like:

export default async function Demo() {
    const user = getUserOrRedirect()
    return <div>Hello {user.firstName}</div>
}

but we want to make sure that name changes appropriately trigger re-renders and you don’t have inconsistencies between different tabs within your application.

Fix: Rely on routing changes

While there are better options in the works, router.refresh() or router.replace(router.asPath) are simple options that will trigger all server components to re-render. You do need to do some work to detect when these changes happen, however.

4. Setting cookies in a server component

While you can read cookies in server components:

export default function CookieDisplayer() {
    const cookieValue = cookies().get("test")?.value
    return <pre>{cookieValue}</pre>
}

And you can call set:

export default async function Header() {
    const abTestingGroup = cookies().get("ab-testing-group")?.value
    if (!abTestingGroup) {
        cookies().set("ab-testing-group", getRandomGroup())
    }
    if (abTestingGroup === "OPTIMISTIC") {
        return <h1>We're the best</h1>
    } else {
        return <h1>Our competitors are the worst</h1>
    }
}

This doesn’t actually work:

The error message here, “Cookies can only be modified in a Server Action or Route Handler” does describe two possible fixes, although we’ll also discuss a third (middleware).

Fix: Use Route Handlers

If you are coming from the pages router, route handlers are basically just API routes. They are served from a route.ts file, and you can set cookies in them:

export async function GET() {
    const abTestingGroup = cookies().get("ab-testing-group")?.value
    const defaultGroup = getRandomGroup()
    if (!abTestingGroup) {
        cookies().set("ab-testing-group", defaultGroup, {httpOnly: true, secure: true, sameSite: "strict"})
    }
    return NextResponse.json({"ab-testing-group": abTestingGroup || defaultGroup})
}

This obviously is a little less convenient than doing it directly in the component, but you can then read these cookies in your server components.

Alternative Fix: Use Middleware

Another option is to use middleware to set the cookie, with a few caveats:

export function middleware(request: NextRequest) {
    const abTestingGroup = request.cookies.get("ab-testing-group")?.value

    // TODO: if it's not set... how do I set this group on the request??

    const nextResponse = NextResponse.next()

    // Make sure to set the cookie on the response
    if (!abTestingGroup) {
        nextResponse.cookies.set("ab-testing-group", getRandomGroup(), {httpOnly: true, secure: true, sameSite: "strict"})
    }
    return nextResponse
}

This makes it easy to set a cookie on the response but not the request. While it might seem natural to just do:

request.cookies.set(...)

This also doesn’t work. There are a few open issues, so it may work someday, but for now, a good workaround is to pass in your own header:

export function middleware(request: NextRequest) {
    const abTestingGroup = request.cookies.get("ab-testing-group")?.value
    const defaultGroup = getRandomGroup()

    const headers = new Headers(request.headers)
    headers.append("X-Ab-Testing-Group", abTestingGroup || defaultGroup)

    const nextResponse = NextResponse.next({
        request: {
            headers
        }
    })

    // Make sure to set a on the response
    if (!abTestingGroup) {
        nextResponse.cookies.set("ab-testing-group", defaultGroup, {httpOnly: true, secure: true, sameSite: "strict"})
    }
    return nextResponse
}

and then you can use the header instead of the cookie in your server components/routes.

Note: Server actions are alpha

As of writing this, server actions are still an alpha feature, so you might want to shy away from this until it’s stable.

5. Trying to pass non-serializable props from server to client

Let’s say we now wanted our facts to have IDs associated with them. For now, we’re just going to hash the text to get an ID. We decide to write a quick class to encapsulate all the logic:

export class CatFactHelper {
    public readonly fact: string
    constructor({ fact }: { fact: string }) {
        this.fact = fact
    }

    public hash(): string {
        return sha256(this.fact)
    }
}

We then make a client component to render the fact and its ID:

"use client"

import {CatFactHelper} from "@/components/CatFact";

type Props = {
    catFactHelper: CatFactHelper
}

export default function CatFactDisplay({catFactHelper}: Props) {
    return <div>
        ID: {catFactHelper.hash()}<br/>
        Fact: {catFactHelper.fact}
    </div>
}

And we update our server component to use this:

export default async function CatFact() {
    const catFactResponse = await fetch("https://catfact.ninja/fact", {
        cache: 'no-store'
    })
    const catFactHelper = new CatFactHelper(await catFactResponse.json())
    return <CatFactDisplay catFactHelper={catFactHelper} />
}

We are met with this strange error:

What’s also strange is if we log the catFactHelper we do see the fact.

However, your IDE can give a good hint at what’s going on:

“Props must be serializable for components in the “use client” entry file”

The problem is our object on the server is serialized to JSON when it’s sent to the client. This object can be serialized to JSON, but the object on the client is equivalent to:

{
  "fact": "the cat fact"
}

instead of the CatFactHelper class with the hash function.

Fix: Only send serializable props to client components

This means no classes or functions. If you really want to use CatFactHelper, you should construct it on the client:

type Props = {
    fact: string
}

export default function CatFactDisplay({fact}: Props) {
    const catFactHelper = new CatFactHelper({fact})
    return <div>
        ID: {catFactHelper.hash()}<br/>
        Fact: {catFactHelper.fact}
    </div>
}

Summary

Server Components are a powerful feature in Next13, but they also have some gotchas to be aware of. In this post, we went over things like re-rendering server components, avoiding importing server components in client components, and when you need to add “use client” to your components.