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.
'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:
'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:
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
]:
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:
// 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
:
/**
* 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:
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:
revalidate
data withrevalidatePath
redirect
to another pagereturn
data
Now, what does fetchServerAction
do?
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:
[ACTION_HEADER]: actionId,
= this allows NextJS to know which backend method to callAccept: 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:
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.