All posts
Tech

Next.js 14 First Look: Turbopack, Server Actions and Partial Prerendering

By Daffa Aditya•8 min read

Next.js 14 came out in October 2023 with three things worth knowing: Turbopack, a faster bundler for the dev server; Server Actions, which let a form call a server function without an API route; and Partial Prerendering, which lets one page be part static and part dynamic. For each one you get what it does, why it exists, and code that works.

I came to version 14 after a break. The last version I had used was Next.js 12, so the speed of the dev server was the first thing I noticed, and that is where this post starts.

Turbopack: a faster bundler

A bundler takes your many source files and turns them into the few files a browser can load. Next.js used webpack for this. Every time you save a file, the bundler rebuilds what changed and Fast Refresh swaps the new code into the open page without losing its state.

Turbopack is a new bundler written in Rust. It remembers the result of every step it has done, so after a change it redoes only the work that the change touches. In development this matters twice: when the server starts, and on every save.

What I measured

This is one cold start on my machine, for a small app with about 20 components, on Next.js 14.1.0. It is one run, not a benchmark.

Dev server start: 1.9 s with Turbopack vs 5 s with webpack
Dev server start: 1.9 s with Turbopack vs 5 s with webpack

The official numbers come from the Next.js team, measured on vercel.com, a large Next.js app: up to 53.3% faster local server start and up to 94.7% faster code updates with Fast Refresh. At the time, 5,000 integration tests for next dev passed with Turbopack, about 90% of the suite. The source is the Next.js 14 release post.

Try it

In Next.js 14, Turbopack is opt-in with the --turbo flag. I kept both scripts, so I could switch:

package.json
{  "scripts": {    "dev": "next dev",    "dev:turbo": "next dev --turbo"  }}

Server Actions: forms without an API route

Before Server Actions, a form that saves data needed two files. First, an API route that runs on the server:

pages/api/submit.ts
import type { NextApiRequest, NextApiResponse } from 'next'export default async function handler(  req: NextApiRequest,  res: NextApiResponse,) {  // The page sends JSON, so Next.js has already parsed it  const data = req.body  // createItem stands for your own database call  const id = await createItem(data)  res.status(200).json({ id })}

Then a page whose submit handler stops the browser and calls that route with fetch:

pages/index.tsx
import { FormEvent } from 'react'export default function Page() {  async function onSubmit(event: FormEvent<HTMLFormElement>) {    event.preventDefault()    const formData = new FormData(event.currentTarget)    const response = await fetch('/api/submit', {      method: 'POST',      headers: { 'Content-Type': 'application/json' },      body: JSON.stringify(Object.fromEntries(formData)),    })    const data = await response.json()    // ...update the page yourself  }  return (    <form onSubmit={onSubmit}>      <input type="text" name="name" />      <button type="submit">Create item</button>    </form>  )}

The same form with 'use server'

In the App Router, the same form fits in one file:

app/page.tsx
import { revalidatePath } from 'next/cache'export default function Page() {  async function create(formData: FormData) {    'use server'    // createItem stands for your own database call    await createItem(formData)    revalidatePath('/')  }  return (    <form action={create}>      <input type="text" name="name" />      <button type="submit">Create item</button>    </form>  )}

The 'use server' line marks create as a Server Action: the function runs only on the server, and its code never reaches the browser. Next.js gives the browser a reference to it instead, an id it can send to ask the server to run it. You pass the function to the form's action prop, not to onSubmit. The revalidatePath('/') call tells Next.js that this page now shows old data, so the response to the form carries the new page.

Because it is a real form with a real action, it also works before the page's JavaScript has loaded. This is called progressive enhancement: the plain HTML works on its own, and JavaScript makes it faster once it arrives. Pick a scenario below and step through what happens on the network.

Server Action, step by step
The Pages Router way: a route file on the server, and a submit handler in the browser that calls it.
plain form POSTPOSTBrowser form<form>Client JSpreventDefault()Next.js serverruns the actionDatabaseitems tableRoute cacherendered pages
Step 1 of 5
Catch the submit
The user presses Submit. Your onSubmit handler calls preventDefault, so the browser does not send the form itself. Your JavaScript takes over.

What actions can do

You can also call an action as a plain async function, without a form. With TypeScript, its arguments and return value are typed on both sides. Inside the action, the Next.js 14 API lets you:

In the Client Component that uses the action, React hooks let you:

The full guide for this version is Server Actions and Mutations in the Next.js 14 docs.

Partial Prerendering (preview in 14)

Before Partial Prerendering (PPR), Next.js rendered each URL one way. A static page (static site generation, SSG) is built once at deploy time and served from a cache, so it is fast but the same for everyone. A dynamic page (server-side rendering, SSR) is rendered on every request, so it can be personal, but even the parts that never change are rendered again for each user. A store page with one personal part, the cart, had to be all dynamic.

PPR splits the page using three ideas:

  • A Suspense boundary is a <Suspense> wrapper in React. It shows a fallback, like a grey skeleton, until the part inside is ready.
  • The static shell is everything outside those boundaries, plus the fallbacks. It is built ahead of time.
  • Streaming means the server sends the response in pieces. The shell goes first, and each dynamic part follows when its data is ready.

Streaming itself was not new. A dynamic route with Suspense already streamed in Next.js 13, but the server still rendered the shell for each request before it could send it. What PPR changes is where the shell comes from: it is built ahead of time and served from the cache, like a static page.

The store page

You do not learn a new API. You put the dynamic parts inside Suspense:

app/page.tsx
import { Suspense } from 'react'import Banner from './banner'import ShoppingCart from './cart'import Recommendations from './recommendations'import NewProducts from './new-products'import { CartSkeleton, ProductListSkeleton } from './skeletons'export default function Page() {  return (    <main>      <header>        <h1>My Store</h1>        <Suspense fallback={<CartSkeleton />}>          <ShoppingCart />        </Suspense>      </header>      <Banner />      <Suspense fallback={<ProductListSkeleton />}>        <Recommendations />      </Suspense>      <NewProducts />    </main>  )}

When a request comes in, the static shell goes out at once. Each fallback marks a hole that the server fills later in the same response:

The static shell
<main>  <header>    <h1>My Store</h1>    <div class="cart-skeleton">      <!-- Hole -->    </div>  </header>  <div class="banner" />  <div class="product-list-skeleton">    <!-- Hole -->  </div>  <section class="new-products" /></main>

The dynamic cart

The cart reads a cookie to find the user's session. A cookie only exists on a real request, so Next.js cannot know it at build time. That one read is what makes the component dynamic:

app/cart.tsx
import { cookies } from 'next/headers'import { getCart } from './data'export default async function ShoppingCart() {  // From Next.js 15: (await cookies()).get('session')  const session = cookies().get('session')?.value  const cart = await getCart(session)  return <div>Cart: {cart.items.length} items</div>}

The cart streams into its hole as part of the same HTTP response as the shell, with no extra request from the browser. If you already use a loading.js file, that is a Suspense boundary too, so those routes need no changes. Switch between the four modes below and move the latencies to see what the user gets and when.

Four ways to render one page
Like SSR + Suspense, but the static shell is built ahead of time and served from the cache at once. Each hole fills in when its own data is ready, in the same HTTP response.
400 ms
1200 ms
First content
0 ms
Cart shows your items
400 ms
Shell
Cart
Products
Holes fill in later in the same HTTP response. A model, not a benchmark: times start when the request reaches the server, the network is left out, cached HTML counts as 0 ms, and rendering the shell on a request is set to 150 ms. The red mark is the first paint.

Turning it on in 14

PPR was a preview in Next.js 14 and not meant for production. You needed the canary release (the prerelease build, installed with npm install next@canary) and an experimental flag:

next.config.js
module.exports = {  experimental: {    ppr: true,  },}

Smaller changes

  • The minimum Node.js version is 18.17.
  • The next export command is removed. Set output: 'export' in next.config.js instead.
  • ImageResponse, for generated social images, moved from next/server to next/og.
  • The viewport, colorScheme and themeColor metadata options are deprecated. Use the new viewport export or generateViewport.
  • In next/image, onLoadingComplete is deprecated in favour of onLoad, and the domains option in favour of remotePatterns.

What to keep

  • Turbopack makes the dev server start and refresh faster.
  • Server Actions replace one-off API routes and keep forms working before JavaScript loads. Each one is a public endpoint, so check the session inside it.
  • Partial Prerendering lets you serve a static shell at once and stream the personal parts behind Suspense.

The note at the top lists what changed after Next.js 14.