Next.js Server Actions

The good, the bad, and the plain undocumented

Posted on in Web
Title slide of the talk: Lessons from using Next.js Server Actions: The good, the bad, and the plain undocumented

This is a (rough) transcript of a talk I gave at work at the end of last year, entitled: "Lessons from using Next.js Server Actions: The good, the bad, and the plain undocumented".

We'd just launched a greenfield product that used Server Actions and this talk was an opportunity to share the approach, top tips, and gotchas that I'd learned along the way.

What are server actions: Stable in Next 14

Server Actions were recently released to stable in Next.js 14.0.0, although they've been developed behind an experimental feature flag for a number of months.

Built into React Canary

Although released as part of Next.js, they are built into React Canary and utilise several 'core' hooks. Although, as you might've read recently, the gap between React Canary and Vercel is hazy at best.

Server actions only ever run on the server

But what exactly are they? They are asynchronous functions that only ever run on the server. Whereas you may currently write what looks like a server function to get some initial page state, you'll find they're regularly called by the client-side as the SPA mode of Next.js takes over.

Server Actions will only run on the server, which brings a tonne of security, performance and accessibility benefits. Next.js provides the tooling to make your application continue to feel like it's making client-side requests, but under the hood, there's a load more native, web-standard plumbing going on.

Think of them as the perfect backend-for-frontend starting point.

Typically receive a FormData class

They typically receive a FormData class as an input parameter…

Return object/redirects

…and they usually return serializable objects or redirects.

The three methods to invoke server actions:

They can be invoked in three ways:

  • An action attribute on a form
  • A formAction attribute on a button
  • Within a startTransition method, returned by the new useTransition hook
Server Components + Server Actions

They play really nicely with Server Components. When understood and used correctly, they beautifully separate the concerns of your HTML rendering path, client-side interactivity, and API requests. They encourage the mindset of responsible server-side rendering.

Example no. 1 - the cookie bar

Let's use an example of a common pattern to explore how Server Actions work. Everyone's favourite feature of the web, the humble cookie bar.

A code snippet of a client-side component that shows/hides a cookie bar

Here's a pseudo-code snippet of a pretty normal implementation of a Cookie bar. Let's walk through it. We pull in useState and a third-party client-side cookie library because it's 2023 and we still need a decent interface for reading/setting cookies. We set up some state for whether the cookie has been set or not by reading in the cookie. Now we have two sources of truth…

Then we create a method to update our state and update the cookie. If the bar has been accepted, we return nothing and if not, we return some markup that renders the bar and calls our method.

A flow diagram of the client-side cookie bar implementation

Let's plot this rendering timeline onto a flow-chart.

The thing about React components is it's easy to forget where they start; rendered on a server. So the request comes into the server and because we're using a client-side cookie library, we don't know whether the cookie has been set or not, so we return nothing. The response gets to the browser and we hit (TTFB).

As the page loads, we also get our (LCP) before the JS is parsed, hydrated and some time later, is interactive. This marks (TTI). Only now we can re-check whether the cookie is set, which may or may not show the cookie bar. If it does, we're in danger of triggering a (CLS). Not good.

Another flow diagram of the client-side cookie bar implementation

When the "Accept cookies" button is clicked, everything now happens on the client side. We set our cookie and state, and React re-renders the component, showing/hiding accordingly.

Code snippet of a server action implementation of a cookie bar

Here's the equivalent using Server Actions. We pull in the cookies helper from within Next.js, and import a server action that we'll look at in a second. Next, we read directly from the helper, returning null if the cookie is set. Now we return a form with the action attribute set to our imported action. Traditionally, you'd put a string in an action attribute, but under the hood, Next resolves and routes this action so it works without JS. Rather than type="button", we can use type="submit". Note, we've not written any client-side code, nor a single event.preventDefault() despite the form.

Code snippet of the cookie bar server action

And here's the first Server Action of the day. It's in a file with a 'use server' directive. This is crucial, and one of the hallmarks of a Server Action. It tells Next to never send this file to the client-side or bundle it into a script so we're safe to work with API secrets and import weightier libraries without penalising our users.

We import the very same cookies helper as we did in our component and do one thing in the action: set the cookie. Nice and simple.

Rendering flow chart of the server action cookie example

The rendering flow chart for this is a dream. The request comes in, with all the lovely headers including any cookies currently set. We either return the markup for the cookie bar, or we don't, but this is all happening on the server. When the response is sent to the client, there's no risk of (CLS).

Interaction flow chart of the server action cookie example

This time, when the button is clicked, a request is sent to the server (via an internal fetch call that Next handles for us). The cookie is checked on the server and the new cookie state is determined. Next then re-renders the component showing/hiding the bar accordingly. From a user's perspective, it's the same outcome, but we've moved the work up to the server. This is a key theme of Server Actions.

Example no. 1.1 - Cookie bar withough Javascript. What happens when JS fails?

Now you may be thinking, why does this matter? Well, let's explore the same component with JS disabled for the inevitable moment when JS fails on your site.

Flow chart of the client-side cookie bar with no JS

Here's the client-side version again, let's click the button…

Everything is broken after the button is clicked.

…sad times. It ded.

Flow chart for the Server Action version

Now, let's compare it to the Server Action version. You click "accept" and perform a traditional POST request to the server. The cookie is set and the new markup is shown without the cookie bar!

Benefit no. 1 - No client-side JS required

You might not think you care about user's without JS, but All your users are non-JS while they're downloading your JS - Jake Archibald.

Benefit no. 2 - Interactive before and during hydration

We've all been on pages where the page looks interactive but you're still waiting for the behemoth bundle to parse and hydrate. If your users interact with a Server Action before JS is ready, they'll just follow the non-JS path, and be able to get on with their task quicker than ever.

Gotcha no. 1 - Cookies can only be set in Server Actions & Route handlers

Gotcha #1 - as you're interacting with actual request headers, cookies can only be set within Server Actions and route handlers. Once the HTML is sent to the browser, the setting stops.

Gotcha no. 2 - setting cookies in middleware requires cloning headers

Gotcha #2 - you can also technically set cookies in Next.js middleware but it requires you to copy headers from the response back to the request or you'll end up in a state where your cookies are a page behind you. This is one of those undocumented features that is only found at the end of a long GitHub issue.

Tip #1 - <noscript> and type="hidden" inputs are surprisingly useful with Server Actions.

Only non-JS users will send the no-js parameter, allowing us handle the two user groups independently.

Onto the next example, a login form. I'll use a simpler "magic link" form to keep things simple.

This is your common or garden React form. Gosh it's something to behold. Let's work through it. After importing various components, it's time to create some reactive state to hold our email address. Then we create a method to handle the form submission. We send the email to an API and if it's successful, route the user on the client side. We'll ignore error handling for now, but that's another load of complexity to add in.

Then, on every key press, we set the entered text into state and re-render the whole component to pass the value back to the input you just typed in. This is the two-way data binding we've all come to accept, but it's actually bonkers when you stop and think about it.

This component covers rendering, API requests and user interactions in the space of 80 lines.

Let's look at a Server Action equivalent form. The difference is staggering.

To begin with, it's so focused. It's only concern is providing the markup to render the form. There's no state management per field, no key press events, and no inline functions. Just HTML (well, JSX, but close enough in this case). It uses web standards that have been around for decades: the name attribute, the form action. These things are battle-tested and just work without a tonne of extra complexity.

The Server Action doesn't need to be complicated either. Next passes in the FormData instance, so we can easily extract the email using the same name attribute, and pass that to our handler. When that's resolved, we redirect the user to the success page.

Here's the flow for the Server Action with JS enabled. The form submits, Next sends the data to the Server Action via a POST request with fetch, and on success, it redirects the users through the client-side router.

Turn off JS and the result is exactly the same. Instead of an AJAX call, a classic POST request happens in the browser, the Server Action handles the login flow, and redirects the user. I would bet money that users would be none the wiser if JS was removed from this page.

And for posterity's sake, here's the client-side version with JS turned off. The cookie bar example may have been contrived, but a login form is a primary action for many sites. Through progressive enhancement, Server Actions provide a way for all users to complete primary tasks on a site in all circumstances.

And moreover, these components are simpler to write and smaller to serve than their client-side counterparts.

The reason this all works so neatly, is because these API's follow the grain of the web. FormData, for example, is a wonderful web native interface that handles forms incredibly elegantly. It doesn't cost the user anything to import, it's well documented and most importantly, it isn't yet another NPM library negatively effecting your project's maintainability.

Another weird gotcha - Redirects and try/catch don't work well together in Server Actions…

If you redirect within the try block, it will throw an error. Use try/catch around asynchronous or volatile calls, then redirect afterwards.

Server Actions make it trivial to use API keys/secrets in a responsible manner. Next already requires environment variables you intent to ship to the client-side to be prefixed with NEXT_PUBLIC_, which means any other variable is only usable in a Server Action file.

Let's carry on from the previous login form example and add some error handling.

It's important to sanitize our inputs and validate data on the server, not just on the client. With all the data automatically send to the server, this becomes the default pattern using Server Actions.

Up until now, all our Server Action forms have been server components, and haven't required any client-side state or hooks. That's going to change in this example. Although it's possible to handle error checking with server components, we're going to use the new useFormState hook imported not from Next, but from react-dom. You need to mark the file with a 'use client' directive.

This hook takes in the Server Action import, and some default state in the shape of an object. It returns an array with a the reactive state, and a new embellished Server Action. These can then be applied to the form and used to render error messages.

Looking at the updated Server Action, you'll see we now return a JSON object if there's an error generated in the try/catch block. This object will be returned to the client and the form updated to show the error message.

The try/catch pattern works really nicely here, allowing us to liberally throw errors in validation libraries, or within the login flow, and handle them in a consistent way.

Here's the flow chart of what's going on, showing the early JSON response if the error is present, and the redirect on success…

…and without JS, it works very similarly…

…but there is a gotcha here. You'll probably remember this classic error message. It appears if you try and refresh a page that has been rendered directly from a POST request. Depending on the task, it could be pretty bad news if a user can resubmit the same form multiple times.

Non-js users can get into this state when we return an error response message. That's not a big deal, but could be if we used useFormState to handle a success state, rather than redirecting the user onto another page.

We can keep things idempotent with the POST → Redirect → GET pattern. This approach ensures that redirects are safe and without side-effects.

Time for another gotcha. The Edge runtime might sound like a U2 effects pedal, but it's the name given to the flavour of limited JS used on CDN servers. At build-time, Next breaks down an application into a bunch of smaller functions that can be distributed to 'Edge' servers and run from there, rather than on a single, central server. This is all very clever and helps with performance, but it's also required to opt-into for Server Actions, specifically if you want to support non-JS requests.

These two, barely documented exports need to be set within the page importing the server action. You won't notice an issue when running the application in development mode, but without export const runtime = 'edge';, non-JS POST requests will just hang indefinently.

A popular JS validation library is Zod. It's very good. It's also recommended on the Next.js Server Actions documentation. It has a FormData plugin designed to take in a POST request and validate accordingly. Unfortunately, that plugin doesn't work with the Edge runtime and thus can't be used with Server Actions if you want to support non-JS users. I'm not sure if anyone at Vercel has noticed.

We use DataDog for logging, monitoring and alerting. But again, there's a gotchat here. I was finding our nice, tidy logs were being desecrated by nasty multi-line logs, but only on some requests. You guessed it, it's our friend the Edge runtime again.

Within our Pino logger, I had to take charge of the logging, but only for requests currently using the Edge runtime in production.

This has formatted logs to a single line, but some events continue to appear as info, despite being classified as error.

The final update to the login form example involves adding a loading state.

useFormStatus from react-dom is the hook needed for this feature. It returns an object with a pending property, which updates when the form is being submitted. We can use this to show a loading/disabled state on the submit button.

Big gotcha for this - useFormStatus must be used in a child component to the form. It cannot be used directly in the parent component.

To finish, I'll run through a few quickfire tips.

useOptimistic can update the UI before the server action completes - perfect for event driven architectures

useOptimistic is a hook provided to update the UI before the Server Action has completed. The classic example for this is an instant messaging application. Jakob's law suggests that you expect to be able to hit enter on a message and it will appear in the message list instantly, rather than waiting for the server to respond positively that the message has been sent. This hook provides some plumbing to make this pattern simpler to implement.

Server Actions can GET data on page load in a server component

Server Actions can be used to fetch data on page/layout load. This is a instance where an action won't receive a FormData object, and can be supplied with whatever parameters you'd like. You're also likely to return an object, rather than a redirect in these instances.

Server actions only every POST data from forms

However, you cannot run a Server Action from a form as a GET request, only a POST. In fact, Next will just steamroll your method parameter and force a POST on there.

revalidatePath - bust the cache on a route before redirecting

CRUD updates are a pretty common usecase for Server Actions. So when you create/update/delete your todo/comment/post etc, you're probably going to want to refresh the list of todos/comments/posts to reflect the change.

revalidatePath accepts a relative URL string where it will flush the cache for any data persisted on that route. When you next visit the route, it will refetch the latest copy of the data, rather than respond with an earlier cached copy.

revalidateTag - mark up your fetch calls with tags and call this in a server action to bust the cache

If you're using the monkey-patched fetch implementation within Next, you're going to want to swat up on caching within the framework. Next now caches everything. If you're dealing with user-specific data, you're going to want to tag up your fetch calls to avoid the risk of cross-pollinating another user's data. I like to include a user ID within the tag to ensure they're unique per user.

revalidateTag accepts one of those tags and busts the cache for it.

Thanks for listening

Thanks for listening reading!


Posted on in Web