codelynx.dev
🇫🇷🇬🇧

Back • 07/10/2024

How Do Server Actions Work in NextJS?

Written by Melvyn Malherbe on 07/10/2024


Server Actions in NextJS have become indispensable, but many people find them "magical" even though they are not. Behind server actions, there is code, logic, and together we will demystify Server Actions.

What is a server action (in brief)?

As a reminder, server actions come into play when you use "use server" in a file, for example, a function that allows you to modify a user's username.

JS
"use server";

export const updateUsernameAction = async (id, username) => {
  const user = await prisma.user.update({
    where: {
      id,
    },
    data: {
      username,
    },
  });
  return user;
};

This method can then be called in your front-end like any other method:

JSX
"use client";

const App = () => {
  return (
    <button onClick={() => updateUsernameAction(1, 'Melvynx')}>
      Update Username
    </button>
  )
}

But by what magic does NextJS manage to define where this updateUsernameAction method comes from and especially which method to call?

How does NextJS find server actions?

Imagine a server action called getServerActionBackendData, NextJS will look at all the methods that our page calls and will include them in the JavaScript bundle.

By looking at the page.js file we receive when we load the page, we can see this:

We can see that in the JavaScript bundle, the getServerActionBackendData method is clearly added. It is defined like this:

JS
var getServerActionBackendData = () => {
  private_next_rsc_action_client_wrapper__WEBPACK_IMPORTED_MODULE_1__.createServerReference("0008bfae0df0222acb8b0c3443136c4c");
};

This method will be injected into our page so that when our client calls it, it is clearly defined.

We can see that it calls a method, createServerReference which is defined in the file [react-server-dom-turbopack-client.browser.development.js]:

JS
exports.createServerReference = function (id, callServer) {
  function action() {
    var args = Array.prototype.slice.call(arguments);
    return callServer(id, args);
  }
  registerServerReference(action, { id: id, bound: null });
  return action;
};

We can see here that what the method will do is take an id then take a method callServer, which we will see next. What the action method does is take all the arguments with which we call this method and come to call callServer with all these arguments and this famous id.

The callServer method is then called with the unique id of our action, and our various arguments. And precisely... what does callServer do:

JS
// The code is shortened for article readability
const serverActionDispatcher: ServerActionDispatcher = useCallback(
  (actionPayload) => {
    startTransition(() => {
      dispatch({
        ...actionPayload,
        type: ACTION_SERVER_ACTION,
      })
    })
  },
  [dispatch]
)
const globalServerActionDispatcher = serverActionDispatcher;

export async function callServer(actionId: string, actionArgs: any[]) {
  const actionDispatcher = globalServerActionDispatcher

  if (!actionDispatcher) {
    throw new Error('Invariant: missing action dispatcher.')
  }

  return new Promise((resolve, reject) => {
    actionDispatcher({
      actionId,
      actionArgs,
      resolve,
      reject,
    })
  })
}

callServer comes back to return a promise that calls a "dispatcher" with the type ACTION_SERVER_ACTION and the arguments passed to the function.

This, in turn, calls a client reducer:

JS
/**
 * Reducer that handles the app-router state updates.
 */
function clientReducer(
  state: ReadonlyReducerState,
  action: ReducerActions
): ReducerState {
  switch (action.type) {
    // Other cases...
    case ACTION_SERVER_ACTION: {
      return serverActionReducer(state, action)
    }
    // This case should never be hit as dispatch is strongly typed.
    default:
      throw new Error('Unknown action')
  }
}

Which finally calls a method serverActionReducer by passing the state as the first parameter and the action as the second. And precisely... what does serverActionReducer do:

JS
export function serverActionReducer(
  state: ReadonlyReducerState,
  action: ServerActionAction
): ReducerState {
  const { resolve, reject } = action
  const mutable: ServerActionMutable = {}
  const href = state.canonicalUrl

  let currentTree = state.tree

  const nextUrl =
    state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree)
      ? state.nextUrl
      : null

  return fetchServerAction(state, nextUrl, action).then(
    async ({
      actionResult,
      actionFlightData: flightData,
      redirectLocation,
      redirectType,
      isPrerender,
      revalidatedParts,
    }) => {
      // Will manage the result
      // If the server action is a redirection = we redirect
      // If the server action comes to revalidate data = we modify the data
      // If the server action returns data = we return them
    },
    (e: any) => {
      // When the server action is rejected we don't update the state and instead call the reject handler of the promise.
      reject(e)

      return state
    }
  )
}

This method is only responsible for calling another method fetchServerAction and retrieving its result. You should know that an action can do several things:

  1. revalidate data with revalidatePath
  2. redirect to another page
  3. return data

Now, what does fetchServerAction do?

JS
async function fetchServerAction(
  state: ReadonlyReducerState,
  nextUrl: ReadonlyReducerState['nextUrl'],
  { actionId, actionArgs }: ServerActionAction
): Promise<FetchServerActionResult> {
  const body = await encodeReply(actionArgs)

  const res = await fetch('', {
    method: 'POST',
    headers: {
      Accept: RSC_CONTENT_TYPE_HEADER,
      [ACTION_HEADER]: actionId,
      [NEXT_ROUTER_STATE_TREE_HEADER]: encodeURIComponent(
        JSON.stringify(state.tree)
      ),
      ...(process.env.NEXT_DEPLOYMENT_ID
        ? {
            'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID,
          }
        : {}),
      ...(nextUrl
        ? {
            [NEXT_URL]: nextUrl,
          }
        : {}),
    },
    body,
  })

  const redirectHeader = res.headers.get('x-action-redirect')
  // ... manages redirection ...

  const isPrerender = !!res.headers.get(NEXT_IS_PRERENDER_HEADER)
  let revalidatedParts: FetchServerActionResult['revalidatedParts']
  // ... manages revalidation ...

  const contentType = res.headers.get('content-type')

  // Retrieve the content returned by the function
  if (contentType?.startsWith(RSC_CONTENT_TYPE_HEADER)) {
    const response: ActionFlightResponse = await createFromFetch(
      Promise.resolve(res),
      { callServer, findSourceMapURL }
    )

    return {
      actionResult: response.a,
      actionFlightData: normalizeFlightData(response.f),
      redirectLocation,
      redirectType,
      revalidatedParts,
      isPrerender,
    }
  }

  return {
    redirectLocation,
    redirectType,
    revalidatedParts,
    isPrerender,
  }
}

This method will perform a fetch API to the URL that is currently displayed. It adds 2 very important data points:

  1. [ACTION_HEADER]: actionId, = this allows NextJS to know which backend method to call
  2. Accept: RSC_CONTENT_TYPE_HEADER, = this tells the backend that we expect a response in "server-action" format

Then, we add the body and other data.

Then we take the result returned from the fetch to rebuild a big object:

JS
return {
  actionResult: response.a, // The result returned from the server action
  redirectLocation, // The redirection if it exists
  redirectType, 
  revalidatedParts, // The revalidation if it exists
  isPrerender,
}

And this is how, subsequently, the serverActionReducer method retrieves this big object to perform the correct action based on the available data.

All of this is what happens under the hood when you call a server action.

How does the NextJS backend handle server-actions?

Without going into the technical details this time, because it is even more complicated, the NextJS backend will define if it is a server action via the header [ACTION_HEADER]: actionId or not.

If it is a server action, it will search for the correct action using the actionId and then call the method.

What we can see is that this actionId is a real router:

Just that instead of having URLs like /api/users/1 we have methods but on the backend, we do pretty much the same thing.

Conclusion

I wanted to show you that server actions are not magic and that ultimately, like everything in tech (it's important to say this), it's always code.

All these frameworks just allow us to simplify the lives of developers by offering a better DX but behind it, it's still the same rules that govern the world of JavaScript.