In today’s fast-paced development environment, building and managing APIs efficiently is more crucial than ever. Whether you’re a beginner or a seasoned developer, you might be familiar with creating REST APIs. However, with the evolution of frameworks like Next.js, there are now multiple ways to handle APIs.
In this blog, we’ll explore 3 ways you can make your APIs fully type-safe and modular using TypeScript in Next.js 14, enhancing your development process and code quality.
The Traditional Approach: REST APIs in Next.js
When working with Next.js, the most common way to create APIs is by using the REST architecture. These APIs are typically housed within the pages/api
directory. Let’s take a quick look at how REST APIs are set up in Next.js:
Creating REST APIs
In a typical Next.js application, you might define your API routes in the api/ping/route.ts
file like this:
filename: api/ping/route.ts
async function getHelloHandler(request: Request) {
// run some backend logic
console.log("Hello from server");
// return a response
return NextResponse.json({ message: "Hello from server get" } as APIResponse);
}
async function postHelloHandler(request: Request) {
// run some backend logic
console.log("Hello from server");
const body = await request.json();
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log(body);
// return a response
return NextResponse.json({
message: "Hello from server post",
} as APIResponse);
}
This simple setup demonstrates two routes: a GET
handler and a POST
handler. Both of them returns a different message. This basic structure is sufficient for many applications, but it lacks some critical features such as error handling, loading state management, and more sophisticated data validation.
Calling REST APIs in the Frontend
In the UI, calling these APIs can involve a fair amount of boilerplate code, especially when dealing with state management for loading, errors, and responses. Typically, you might use the fetch
API to make these requests, which requires additional code to handle these states:
filename: restapi/page.tsx
"use client";
import { Box, Button, CircularProgress, Typography } from "@mui/material";
import { useEffect, useState } from "react";
interface RestApiResponse {
message: string;
}
export default function RestApiDemoPage() {
const [getResponse, setGetResponse] = useState<RestApiResponse | null>(null);
const [postResponse, setPostResponse] = useState<RestApiResponse | null>(
null
);
const [getIsLoading, setGetIsLoading] = useState(false);
const [postIsLoading, setPostIsLoading] = useState(false);
// Make a GET request to the server on page load
useEffect(() => {
setGetIsLoading(true);
const controller = new AbortController();
fetch("/api/ping", { signal: controller.signal })
.then((res) => res.json())
.then((data: RestApiResponse) => setGetResponse(data))
.catch((err) => {
console.error(err);
});
setGetIsLoading(false);
return () => {
controller.abort();
};
}, []);
// Make a POST request to the server
const handlePostPing = (data: any) => {
setPostIsLoading(true);
console.log("Sending POST request with data: ", data);
fetch("/api/ping", {
method: "POST",
body: JSON.stringify(data),
})
.then((res) =>
res.ok ? res.json() : console.log(res.status, res.statusText)
)
.then((data: RestApiResponse) => setPostResponse(data))
.catch((err) => {
console.error(err);
});
setPostIsLoading(false);
};
return (
<Box
display="flex"
height="100vh"
flexDirection="column"
justifyContent="center"
alignItems="center"
>
<Box>
<Typography variant="h5">
GET from <code>/api/ping</code>
</Typography>
<Typography
mb={2}
variant="body2"
color="success"
sx={{
backgroundColor: "beige",
borderRadius: 2,
width: 300,
padding: 2,
}}
>
{getResponse ? (
<pre>
<code>{JSON.stringify(getResponse, null, 2)}</code>
</pre>
) : getIsLoading ? (
<CircularProgress />
) : (
"No data"
)}
</Typography>
</Box>
<br />
<Box>
<Typography variant="h5">
POST <code>/api/ping</code>
</Typography>
<Typography
mb={2}
variant="body2"
color="success"
sx={{
backgroundColor: "beige",
borderRadius: 2,
width: 300,
padding: 2,
}}
>
{postResponse ? (
<pre>
<code>{JSON.stringify(postResponse, null, 2)}</code>
</pre>
) : postIsLoading ? (
<CircularProgress />
) : (
"No data"
)}
</Typography>
<Button
variant="contained"
onClick={() => handlePostPing({ name: "John Doe", role: "ADMIN" })}
>
Send POST request
</Button>
</Box>
</Box>
);
}
Using SWR for GET and POST Requests
Here we can leverage to controllers created in api/ping/route.ts
so we need not define new controllers anymore.
SWR automatically manages the data fetching, error handling, and loading states, significantly reducing the code you need to write and maintain.
While SWR initially supported only GET requests, it now also provides support for mutations (POST, PUT, DELETE, etc.). Here’s how you can use SWR to handle GET and POST requests:
filename: swrapi/page.tsx
"use client";
import { Box, Button, CircularProgress, Typography } from "@mui/material";
import useSWR from "swr";
import useSWRMutation from "swr/mutation";
async function sendRequest<T>(url: string, { arg }: { arg: T }) {
return fetch(url, {
method: "POST",
body: JSON.stringify(arg),
}).then((res) => res.json());
}
export default function SWRAPIResponsePage() {
const {
trigger,
isMutating,
error: postPingError,
data: postPingData,
} = useSWRMutation("/api/ping", sendRequest<ICreateUser>);
const {
data: getResponse,
error: getErorr,
isLoading: getIsLoading,
} = useSWR("/api/ping");
const handlePostPing = async () => {
const postData: ICreateUser = { name: "John Doe", role: "ADMIN" };
await trigger(postData);
};
return (
<Box
display="flex"
height="100vh"
flexDirection="column"
justifyContent="center"
alignItems="center"
>
<Box>
<Typography variant="h5">
GET from <code>/api/ping</code>
</Typography>
<Typography
mb={2}
variant="body2"
color="success"
sx={{
backgroundColor: "beige",
borderRadius: 2,
width: 300,
padding: 2,
}}
>
{getResponse ? (
<pre>
<code>{JSON.stringify(getResponse, null, 2)}</code>
</pre>
) : getErorr ? (
<pre>
<code>{JSON.stringify(getErorr, null, 2)}</code>
</pre>
) : getIsLoading ? (
<CircularProgress />
) : (
"No data"
)}
</Typography>
</Box>
<br />
<Box>
<Typography variant="h5">
POST <code>/api/ping</code>
</Typography>
<Typography
mb={2}
variant="body2"
color="success"
sx={{
backgroundColor: "beige",
borderRadius: 2,
width: 300,
padding: 2,
}}
>
{postPingData ? (
<pre>
<code>{JSON.stringify(postPingData, null, 2)}</code>
</pre>
) : isMutating ? (
<CircularProgress />
) : postPingError ? (
<pre>
<code>{JSON.stringify(postPingError, null, 2)}</code>
</pre>
) : (
"No data"
)}
</Typography>
<Button variant="contained" onClick={() => handlePostPing()}>
Send POST request
</Button>
</Box>
</Box>
);
}
This setup allows you to manage POST requests with minimal code, leveraging the power of SWR to handle various states.
Creating and Using API Requests with React Query
React Query does similar things as SWR, however they are not built to work with existing handlers defined in NextJS API folder. We have to make separate controllers for them.
Here, In addition to calling hooks from react-query, I have abstracted API calls into custom hooks, making your components cleaner and more focused on rendering UI:
filename: page.tsx
"use client";
import { useGetPing, usePostPing } from "@/components";
import { Box, Button, CircularProgress, Typography } from "@mui/material";
export function useGetPing(id: number = 0) {
return useQuery({
queryKey: ["get-ping"],
queryFn: async (id: number) => {
"use server"
// run some backend logic here
console.log(id);
return { status: "success", message: "pong using query" };
},
refetchOnMount: false,
});
}
function usePostPing() {
return useMutation({
mutationKey: ["post-ping"],
mutationFn: async (data: ICreateUser): Promise<APIResponse> => {
"use server"
// Run some backend logic here
console.log(data);
// Simulate a delay of 2 seconds
await new Promise((resolve) => setTimeout(resolve, 2000));
return { status: "success", message: "pong using mutation" };
},
});
}
export default function Home() {
const { data: pingData } = useGetPing();
const {
data: pingPostData,
isPending: pingPostIsPending,
isSuccess: pingPostIsSuccess,
isError: pingPostIsError,
error: pingPostError,
mutate: pingPostMutate,
} = usePostPing();
return (
<main>
<Box
display="flex"
height="100vh"
flexDirection="column"
justifyContent="center"
alignItems="center"
>
<Box>
<Typography variant="h5">
GET from <code>useQuery</code>
</Typography>
<Typography
mb={2}
variant="body2"
color="success"
sx={{
backgroundColor: "beige",
borderRadius: 2,
width: 300,
padding: 2,
}}
>
<pre>
<code>{JSON.stringify(pingData, null, 2)}</code>
</pre>
</Typography>
</Box>
<br />
<Box>
<Typography variant="h5">
POST to <code>useMutation</code>
</Typography>
<Typography
mb={2}
variant="body2"
color="success"
sx={{
backgroundColor: "beige",
borderRadius: 2,
width: 300,
padding: 2,
}}
>
{pingPostIsPending ? (
<CircularProgress />
) : pingPostIsSuccess ? (
<pre>
<code>{JSON.stringify(pingPostData, null, 2)}</code>
</pre>
) : pingPostIsError ? (
<pre>
<code>{JSON.stringify(pingPostError, null, 2)}</code>
</pre>
) : (
"No data"
)}
</Typography>
<Button
variant="contained"
onClick={() => pingPostMutate({ name: "John Doe", role: "ADMIN" })}
>
Send POST request
</Button>
</Box>
</Box>
</main>
);
}
Choosing between SWR and React Query
For the majority of application use cases, I recommend using SWR as your primary package. It has a gentle learning curve and offers all the features needed to build nearly 90% of applications. You can trust SWR for its reliability and seamless compatibility, as it’s developed and maintained by the creators of Next.js.
For more complex applications, you might consider using TanStack React Query, a robust library for managing server state in React applications. React Query offers more features out-of-the-box compared to SWR, including built-in support for pagination, infinite scrolling, and more sophisticated caching strategies. However, this power comes at the cost of increased complexity and a larger bundle size.
Conclusion
In summary, Next.js 14 offers several ways to manage APIs, from traditional REST APIs to more advanced libraries like SWR and React Query. By integrating TypeScript into your Next.js project, you can take full advantage of type safety, leading to more maintainable and error-free code. Whether you choose SWR for its simplicity and small bundle size or React Query for its rich feature set, both options will significantly improve your API management experience.