One of the most exciting features of Next.js is its ability to be a full-stack framework, that allows us to write a complete back-end using functions and to call them from the same codebase.
Here is a quick guide to writing API endpoints with Next.js and how to call them from our components.
Creating API endpoints with Next.js
Adding an API handler to your Next.js application is as easy as creating a function within the /pages/api
directory. For example, let's create an API handler that returns Hello World
:
- Create a file at
/pages/api/hello-word.ts
- Export a default function that handles the request
export default function helloWorldHandler( req: NextApiRequest, res: NextApiResponse,) { res.send(`Hello World`);}
The API endpoint is global, i.e. above will respond to any HTTP method (GET, POST, PUT, DELETE...) with the same response.
If you want to respond differently based on the method, you will need some logic such as a switch statement:
export default function helloWorldHandler( req: NextApiRequest, res: NextApiResponse,) { switch (req.method.toLowerCase()) { case `get`: return res.send(`Hello World`); case `post`: // do something else default: // handle unsupported methods return res.status(501).end(); }}
And that's pretty much it: Next.js functions are extremely easy to write, but it takes some mastery to write more complex controllers. This is why it's often recommended to write the logic outside of the functions and simply use them for the routing logic.
For example, you could use something like the below:
import helloWordController from '~/controllers/hello-world';export default function helloWorldHandler( req: NextApiRequest, res: NextApiResponse,) { return helloWordController(req, res)}
Now let's write the controller:
const get = function( req: NextApiRequest, res: NextApiResponse,) { return res.send(`Hello World`);};const post = function( req: NextApiRequest, res: NextApiResponse,) { // handle post request};const methodsMap = { get, post,};export default function helloWordController( req: NextApiRequest, res: NextApiResponse,) { const method = req.method.toLowerCase(); if (!method in methodsMap) { return res.status(501).end(); } return methodsMap[method](req, res);}
The above is a very simple way to split your different HTTP handlers into smaller methods based on the HTTP method provided, but the goal is to show how flexible it is to write Next.js API functions that can scale well.
Hook to make HTTP requests to a Next.js API endpoint
Now that we have written our Next.js endpoints, we need a way to call the API from our Next.js components. First of all, we want to use a standard way to do so, which in 2022 means making a React hook.
We will write two React Hooks:
- The first (
useRequestState
) will manage the request state (and is reusable for any asynchronous operation) - The second (
useApi
) will use the first hook and is responsible for creating an HTTP request that we can call from our React components
Creating a React Hook to manage async state
The following hook is very simple: it's a sort of state machine that flips the state based on the result of a request. It will return a state object with the following interface:
type State<Data, ErrorType> = | { data: Data; loading: false; success: true; error: Maybe<ErrorType>; } | { data: undefined; loading: true; success: false; error: Maybe<ErrorType>; } | { data: undefined; loading: false; success: false; error: Maybe<ErrorType>; };
The state will change based on the result of the async operation:
- Initially everything is
undefined
- When the request starts, we flip the boolean
loading
totrue
- When the request errors out, we define the
error
property and unsetloading
- When the request succeeds, we define the
data
property and unset botherror
andloading
export function useRequestState<Data = unknown, ErrorType = unknown>() { const [state, setState] = useState<State<Data, ErrorType>>({ loading: false, success: false, error: undefined, data: undefined, }); const setLoading = useCallback((loading: boolean) => { setState({ loading, success: false, data: undefined, error: undefined, }); }, []); const setData = useCallback((data: Data) => { setState({ data, success: true, loading: false, error: undefined, }); }, []); const setError = useCallback((error: ErrorType) => { setState({ data: undefined, loading: false, success: false, error, }); }, []); return { state, setState, setLoading, setData, setError, };}
Now that we have a way to manage the lifecycle of an HTTP request (or any other asynchronous operation), we will define a hook that uses fetch
to make an HTTP request to our API endpoints.
We call this hook useApiRequest
(or useFetch
if you prefer), which takes two parameters:
path
: the path to call (ex./api/hello-world
)method
: the method to use (such as POST, GET, etc.)
The hook will return the following array: [callback, state]
:
callback
is a function to execute the fetch requeststate
is the current state of the request returned byuseRequestState
Below is the full code for calling the HTTP request:
export function useApiRequest<Resp = unknown, Body = void>( path: string, method: HttpMethod = 'POST') { const { setError, setLoading, setData, state } = useRequestState< Resp, string >(); const fn = useCallback( async (body: Body) => { setLoading(true); try { const payload = JSON.stringify(body); const data = await executeFetchRequest<Resp>(path, payload, method); setData(data); } catch (error) { const message = error instanceof Error ? error.message : `Unknown error`; setError(message); return Promise.reject(error); } }, [path, method, setLoading, setData, setError] ); return [fn, state] as [typeof fn, typeof state];}async function executeFetchRequest<Resp = unknown>( url: string, payload: string, method = 'POST') { const options: RequestInit = { method, headers: { accept: 'application/json', 'Content-Type': 'application/json', }, }; const methodsSupportingBody: HttpMethod[] = ['POST', 'PUT']; const supportsBody = methodsSupportingBody.includes(method as HttpMethod); if (payload && supportsBody) { options.body = payload; } try { const response = await fetch(url, options); if (response.ok) { return (await response.json()) as Promise<Resp>; } return Promise.reject(response.statusText); } catch (e) { return Promise.reject(e); }}
Calling API endpoints from Next.js components
Now that we defined the useApiRequest
hook, we can use it to execute requests from our components. generally speaking, there are three situations when we want to execute an HTTP function within components:
- to fetch the initial component data
- to continually fetch data from the same component when some dependent property changes
- to make a request to respond to the user's interaction (for example, submitting a form)
Fetching initial component data with Next.js from an API endpoint
One of the most common ways we use API endpoints is when we need to retrieve data to display in components.
export default function MembersPage() { const [ fetchMembers, { data, loading, error } ] = useApiRequest<Member[]>(`/api/members`, `GET`); useEffect(() => { void fetchMembers(); }, []); if (error) { return <p>Error: {error}</p>; } if (loading) { return <p>Fetching Members...</p>; } return <MembersList members={data} />}
What happens in the component above?
- First, we use the hook to create an HTTP request to the
/api/members
endpoint - We use
useEffect
to fetch data when the component loads (only once!) - We display the correct data based on the state of the request (error, loading or success)
Fetching data on changes with Next.js from an API endpoint
It's very common having to refresh the component's data following some changes in some properties, for example, a component's property.
In the code below, we add the organizationId
property to specify which organization members to fetch using a path parameter. The challenge is to re-fetch the data when organizationId
changes. To do so, we add a dynamic dependency to useEffect
, which is the path of the API endpoint to request.
type Response = Member[];export default function MembersComponent( props: React.PropsWithChildren<{ organizationId: string }>) { const path = `/api/organizations/${props.organizationId}/members`; const [ fetchMembers, { data, loading, error } ] = useApiRequest<Member[]>(path, `GET`); // re-execute effect when ${path} changes useEffect(() => { void fetchMembers(); }, [path]); if (error) { return <p>Error: {error}</p>; } if (loading) { return <p>Fetching Members...</p>; } return <MembersList members={data} />}
Sending data to an API endpoint with Next.js after a submission
Another scenario is sending data to the API upon user interaction, like submitting a form. In the code below, we write a function to invite a member to an organization when the form is submitted.
To do so, we extract some logic as its own hook:
interface Invite { email: string; role: MembershipRole;}export function useInviteMember(organizationId: string) { return useApiRequest<void, Invite>( `/api/organizations/${organizationId}/invite` );}
Now, we import the hook useInviteMember
in our form component which will handle the form submission and display the correct content based on the state of the request:
export default function InviteMemberContainer( props: React.PropsWithChildren<{ organizationId: string }>) { const [inviteMember, {loading, error, success }] = useInviteMember(props.organizationId); const onSubmit = useCallback(async (invite: Invite) => { await inviteMember(invite); }); if (loading) { return <p>Inviting member...</p>; } if (error) { return <p>Error encountered while inviting member :(</p>; } if (success) { return <p>Member successfully invited!</p>; } return <InviteMemberForm onSubmit={onSubmit} />}
And that's it!
What have we learned?
In this blog post we learned how to:
- create an asynchronous state reducer with the
useRequestState
hook - create a hook to send requests to our Next.js endpoint using
fetch
- using HTTP requests within our Next.js components to send and receive data in various common scenarios
All these patterns are used in the Makerkit SaaS boilerplate for Next.js and Firebase. If you have purchased a license or want to, the examples above are incredibly helpful to get familiar with the patterns used in the SaaS template.
If you need any help, do not hesitate to contact me!