-
Notifications
You must be signed in to change notification settings - Fork 0
Creating Server Queries
Important
Everything described on this page is for the Next.js server side!
So it is safe to use e.g. credentials from env variables, or directly fetch data from the database.
| Name | Description |
|---|---|
| Server Action | The native server action by React and Next.js. Used for user actions |
| Server Query | Used for fetching data using a custom fetch request via the GET method. |
| Server Mutation | Used for data mutation on the server. This is for special occasions only. Has the same goal as a server action, but uses a custom fetch request and can be executed in parallel. Always use server actions for user actions. Only use this if you encounter a limitation with server actions -> you will know, when you need this 😉 |
The basic idea is that each server action, query and mutation should behave the same under the hood.
The implementation has the following constraints:
- Input (if any) is always validated via a Zod Schema
- Returned errors must follow a standardised format (JSON:API specification) to ensure consistency
- Return value of the success value can be freely chosen and is inferred by typescript
flowchart TD
A[Incoming Request] --> C{Validate Input}
C -->|Validation ErrorResult| Z[Response]
C -->|Validation Success| D{Execute Handler}
D -->|ErrorResult| Z[Response]
D -->|SuccessResult| Z[Response]
As recommended by the Next.js docs, it is very important to validate the input parameters of a server action, query or mutation. These functions have open API endpoints after all.
Validation is done via the awesome Zod library. When creating a server action, query or mutation a Zod schema can be passed in the factory function to indicate that input parameters are needed. The input parameter types are automatically inferred from the schema in TypeScript.
Should the input parameters not match the schema, a error result is returned containing all the validation errors.
Error handling in this library follows a rather opinionated approach, by having the handler functions return a ServerQueryResult type.
This is very similar to the Result type in rust, that hold either a Ok or a Err value. It turned out that this approach provides a more flexible and convenient way of handling errors compared to the native try/catch error handling.
To ensure consistency across all errors (there may and will be more than one possible error returned from a handler), errors are expected to follow the JSON:API format. (See the specification here: >JSON:API docs<) This format is very well standardized and contains all necessary data to reflect even the most complex errors.
The type of the error must match the following interface:
export interface ServerQueryError<TMeta = Record<string, unknown>> {
id?: string;
links?: {
about?: string;
type?: string;
};
status?: string;
code?: string;
title?: string;
detail?: string;
source?: {
pointer?: string;
parameter?: string;
header?: string;
};
meta?: TMeta;
}As seen by the optional properties, a ServerQueryErr can contain much information, but everything is optional, so at least one property must be set.
Example
The handler functions of a server action, query or mutation must return either the error or ok result using custom factory functions:
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
// ...
// This is just the handler of e.g. a server query.
async () => {
if (userHasMissingPrivilege) {
return ServerQueryErr({
code: "access_denied",
title: "Access Denied",
detail: "User does not have sufficient privileges to access this query",
status: 403,
meta: {
reason: "missing_privilege",
},
});
}
if (dataFetchingFailed) {
return ServerQueryErr({
code: "fetch_failed",
title: "Data Fetching Failed",
detail: "Could not fetch data from the backend",
});
}
return ServerQueryOk({
status: "success",
data: {
// Some arbitrary data...
},
});
}The server query is only used to fetch data. It supports input parameters, but those are completely optional.
Consumption of the server query happens via custom React hooks wrapping useQuery and useInfiniteQuery from @tanstack/react-query.
Note
Read more about how to consume a server query, mutation and action in the Clients section.
A server query can be defined in it's own file by exporting a variable holding a server query function created by the serverQuery factory function.
The query needs a unique ID (string) set as the first argument. This ID is then used in the fetch request to identify this query.
Next, pass in a Zod schema if the query needs input parameters. This can be omitted, if no input is needed.
The last argument is the handler function, which is an async function taking in the input variables (the type is automatically inferred from the Zod schema, if set).
Only a ServerQueryErr or ServerQueryOk may be returned from the handler function. This is to ensure that data is serializable and follows a standardized pattern to ensure consistency.
// Important safety-net when accessing this from a client, or if the loader plugin does somehow not work correctly.
import "server-only";
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
import { serverQuery } from "server-queries/server";
import { z } from "zod";
const schema = z.object({
topic: z.enum(["cooking", "sports"]),
});
export const getUserPostsQuery = serverQuery(
"get-user-posts", // ‼️ The ID of the query (this is important later when importing this query in client code!)
schema, // The schema for input parameters.
async (input) => { // The actual handler function with the input parameters as first argument.
const session = await getSessionSomehow();
if (!session.authenticated) {
return ServerQueryErr({
code: "unauthenticated",
title: "Unauthenticated",
detail: "User must be logged in to use this resource"
});
}
try {
const response = await fetch(
`/api/user/${session.userId}/posts/${input.topic}`,
{
method: "GET",
},
);
if (!response.ok) {
// Optionally, parse the response body if the backend returns
// a error with more information.
return ServerQueryErr({
code: "fetch_error",
message: "Failed to fetch user posts",
status: response.status.toString(),
});
}
const data = await response.json();
// Return the data here as `ServerQueryOk`.
return ServerQueryOk(data);
} catch (e) {
return ServerQueryErr({
code: "fetch_error",
title: "Failed to fetch user posts",
detail: e instanceof Error ? e.message : undefined,
status: "500",
});
}
},
);// Important safety-net when accessing this from a client, or if the loader plugin does somehow not work correctly.
import "server-only";
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
import { serverQuery } from "server-queries/server";
export const getUserDataQuery = serverQuery(
"get-user-data", // ‼️ The ID of the query (this is important later when importing this query in client code!)
async () => { // The actual handler function with the input parameters as first argument.
const session = await getSessionSomehow();
if (!session.authenticated) {
return ServerQueryErr({
code: "unauthenticated",
title: "Unauthenticated",
detail: "User must be logged in to use this resource"
});
}
try {
const response = await fetch(
`/api/user/${session.userId}`,
{
method: "GET",
},
);
if (!response.ok) {
// Optionally, parse the response body if the backend returns
// a error with more information.
return ServerQueryErr({
code: "fetch_error",
message: "Failed to fetch user data",
status: response.status.toString(),
});
}
const data = await response.json();
// Return the data here as `ServerQueryOk`.
return ServerQueryOk(data);
} catch (e) {
return ServerQueryErr({
code: "fetch_error",
title: "Failed to fetch user data",
detail: e instanceof Error ? e.message : undefined,
status: "500",
});
}
},
);This library also provides a factory function for creating server actions that follow the same principles as server queries and mutations. By using this factory function, input data is automatically validated via Zod and return values are standardized.
Server Actions are only for user actions! Do not use them for fetching data, only for mutating data after the user interacted with your app.
Note
Read more about how to consume a server query, mutation and action in the Clients section.
A server action can be created in the exact same way as the server query above, just by using the serverAction factory function.
It supports both input parameters by passing in a Zod schema, or no input parameters by omitting it.
Contrary to server queries and mutations, the server action does NOT need a ID set as the first argument, due to the request being handled by React / Next.js automatically.
The handler function of a server action must only return either a ServerQueryErr or ServerQueryOk object.
"use server";
import { revalidateTag } from "next/cache";
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
import { serverAction } from "server-queries/server";
import { z } from "zod";
const schema = z.object({
nickname: z.string(),
});
export const updateOwnUserNicknameAction = serverAction(
schema, // The schema for input parameters.
async (input) => { // The actual handler function with the input parameters as first argument.
const session = await getSessionSomehow();
if (!session.authenticated) {
return ServerQueryErr({
code: "unauthenticated",
title: "Unauthenticated",
detail: "User must be logged in to use this resource"
});
}
try {
const response = await fetch(
`/api/user/${session.userId}`,
{
method: "PATCH",
body: JSON.stringify({
nickname: input.nickname,
}),
headers: {
"Content-Type": "application/json",
}
},
);
if (!response.ok) {
// Optionally, parse the response body if the backend returns
// a error with more information.
return ServerQueryErr({
code: "fetch_error",
message: "Failed to update user",
status: response.status.toString(),
});
}
const data = await response.json();
// Revalidate Next.js caches here.
revalidateTag('user-data');
// Return the data here as `ServerQueryOk`.
return ServerQueryOk(data);
} catch (e) {
return ServerQueryErr({
code: "fetch_error",
title: "Failed to update user",
detail: e instanceof Error ? e.message : undefined,
status: "500",
});
}
},
);"use server";
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
import { serverAction } from "server-queries/server";
export const updateUserTimestampAction = serverAction(
async () => { // The actual handler function with the input parameters as first argument.
const session = await getSessionSomehow();
if (!session.authenticated) {
return ServerQueryErr({
code: "unauthenticated",
title: "Unauthenticated",
detail: "User must be logged in to use this resource"
});
}
try {
const response = await fetch(
`/api/user/${session.userId}`,
{
method: "PATCH",
body: JSON.stringify({
timestamp: new Date().getTime(),
}),
headers: {
"Content-Type": "application/json",
}
},
);
if (!response.ok) {
// Optionally, parse the response body if the backend returns
// a error with more information.
return ServerQueryErr({
code: "fetch_error",
message: "Failed to update user",
status: response.status.toString(),
});
}
const data = await response.json();
// Return the data here as `ServerQueryOk`.
return ServerQueryOk(data);
} catch (e) {
return ServerQueryErr({
code: "fetch_error",
title: "Failed to update user",
detail: e instanceof Error ? e.message : undefined,
status: "500",
});
}
},
);A server mutation has the same purpose as a server action, but follows a very similar implementation of a server query, meaning it is called via a custom fetch request with the POST HTTP method.
Important
It is always preferred to use a server action for any kind of user mutation! The only difference of a server mutation to a server action is that it can run in parallel, where execution of multiple server actions always happens sequentially.
You will know, when you need a server mutation instead of a server action, when you hit such limitation in your app. One such use case would be to trigger an event from a user action for analytics, but due to this happening transparently in the background it would degrade the user experience if a real user interaction (e.g. user clicking the submit button on a form) would have to wait for this event to be processed. In such case triggering the event via a server mutation would make more sense.
Note
Read more about how to consume a server query, mutation and action in the Clients section.
A server mutation can be created in the exact same way as the server query above, just by using the serverMutation factory function.
It supports both input parameters by passing in a Zod schema, or no input parameters by omitting it.
The handler function of a server mutation must also only return either a ServerQueryErr or ServerQueryOk object.
"use server";
import { revalidateTag } from "next/cache";
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
import { serverMutation } from "server-queries/server";
import { z } from "zod";
const schema = z.object({
nickname: z.string(),
});
export const updateOwnUserNicknameMutation = serverMutation(
"update-own-user-nickname", // ‼️ The ID of the mutation (this is important later when importing this query in client code!)
schema, // The schema for input parameters.
async (input) => { // The actual handler function with the input parameters as first argument.
const session = await getSessionSomehow();
if (!session.authenticated) {
return ServerQueryErr({
code: "unauthenticated",
title: "Unauthenticated",
detail: "User must be logged in to use this resource"
});
}
try {
const response = await fetch(
`/api/user/${session.userId}`,
{
method: "PATCH",
body: JSON.stringify({
nickname: input.nickname,
}),
headers: {
"Content-Type": "application/json",
}
},
);
if (!response.ok) {
// Optionally, parse the response body if the backend returns
// a error with more information.
return ServerQueryErr({
code: "fetch_error",
message: "Failed to update user",
status: response.status.toString(),
});
}
const data = await response.json();
// Revalidate Next.js caches here.
revalidateTag('user-data');
// Return the data here as `ServerQueryOk`.
return ServerQueryOk(data);
} catch (e) {
return ServerQueryErr({
code: "fetch_error",
title: "Failed to update user",
detail: e instanceof Error ? e.message : undefined,
status: "500",
});
}
},
);"use server";
import { ServerQueryErr, ServerQueryOk } from "server-queries/results";
import { serverMutation } from "server-queries/server";
export const updateUserTimestampMutation = serverMutation(
"update-user-timestamp", // ‼️ The ID of the mutation (this is important later when importing this query in client code!)
async () => { // The actual handler function with the input parameters as first argument.
const session = await getSessionSomehow();
if (!session.authenticated) {
return ServerQueryErr({
code: "unauthenticated",
title: "Unauthenticated",
detail: "User must be logged in to use this resource"
});
}
try {
const response = await fetch(
`/api/user/${session.userId}`,
{
method: "PATCH",
body: JSON.stringify({
timestamp: new Date().getTime(),
}),
headers: {
"Content-Type": "application/json",
}
},
);
if (!response.ok) {
// Optionally, parse the response body if the backend returns
// a error with more information.
return ServerQueryErr({
code: "fetch_error",
message: "Failed to update user",
status: response.status.toString(),
});
}
const data = await response.json();
// Return the data here as `ServerQueryOk`.
return ServerQueryOk(data);
} catch (e) {
return ServerQueryErr({
code: "fetch_error",
title: "Failed to update user",
detail: e instanceof Error ? e.message : undefined,
status: "500",
});
}
},
);