Skip to content

Building full-stack React apps with Next.js API routes

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Besides Next.js being a great front-end framework for React, it also provides a simple way to build an API for your front-end - all in the same web app!

As of version 9, Next.js provides API routes that allows developers to create API endpoints using the Next.js folder structure. We can use API endpoints to build either a RESTful API, or a GraphQL API. This article will focus on a RESTful API for the sake of simplicity.

With Next.js API routes, there's no longer a need to set up a back-end Node.js server to build an API. Building out two separate projects for the front-end and back-end of an application introduces its own set of challenges. Next.js simplifies this process by becoming a full-stack web framework with the addition of API routes. Building and deploying a full-stack web app has never been faster and easier!

Setting up API routes

API routes go in the pages/api directory of a Next.js project.

When a file or folder is added to this pages/api folder, Next.js will create an API endpoint URL for it. Creating a pages/api/users.ts file will create an /api/users API endpoint. We can also create an /api/users API endpoint by creating a pages/api/users/index.ts file.

To create a dynamic API route for a specific user, we can create a pages/api/users/[id].ts file. This dynamic route will match requests such as /api/users/1.

Just like the pages folder maps its files and folders to URLs that can be visited in a web browser, the pages/api folder maps its files and folders to API endpoint URLs.

Creating API routes

To begin, let's create a new Next.js project called nextjs-full-stack.

npx create-next-app full-stack-nextjs

cd full-stack-nextjs

If you prefer using TypeScript with Next.js, you can use npx create-next-app --ts instead. For this article, we'll just use JavaScript.

Notice that newly created Next.js project contains a pages/api/hello.js file. An /api/hello API endpoint has already been provided for us. Let's run the project using npm run dev to try this API endpoint.

Visit http://localhost:3000/api/hello in a web browser. You should see the following response.

{ "name": "John Doe" }

Understanding API routes

Let's take a look at the code in the pages/api/hello.js file.

export default function handler(req, res) {
  res.status(200).json({ name: 'John Doe' })
}

The default export in files that are found within the api folder must be a function. Each function is a handler for a particular route. Asynchronous functions are also supported for cases where requests need to be made to external APIs or a database.

When running the Next.js project locally, it creates a Node.js server and passes the request and response objects from the server into the handler function of the requested endpoint.

A dynamic route

Let's create a pages/api/users/[id].js file to create an API route for a specific user. Let's add the following code within this file.

const users = [
	{ id: 1, name: 'John Smith' },
	{ id: 2, name: 'Jane Doe' },
];

export default (req, res) => {
  const { query: { id } } = req;

  res.json({ 
    ...users.find(user => user.id === parseInt(id)),
  });
}

For the purposes of creating an example, we mocked up a list of users within the file. More realistic usage might involve querying a database for the requested user.

The id value is retrieved from the request query parameter, and then used to find the corresponding user object in the list of users. Keep in mind that the id from the request is a string, so we must parse it to an integer in order to perform a user lookup by id on the users list.

Visit http://localhost:3000/api/users/1 in a web browser. You should see the following response.

{ "id": 1, "name": "John Smith" }

Returning an error

We can modify the user endpoint we just created to return an error when the requested user id is not found in a list of users.

const users = [
	{ id: 1, name: 'John Smith' },
	{ id: 2, name: 'Jane Doe' },
];

export default (req, res) => {
  const { query: { id } } = req;

  const user = users.find(user => user.id === parseInt(id));
  if (!user) {
    return res.status(404).json({
      status: 404,
      message: 'Not Found'
    });
  }

  res.json({ ...user });
}

Visit http://localhost:3000/api/users/3 in a web browser. You should see the following response since no user with an id of 3 exists.

{ "status": 404, "message": "Not Found" }

Handling multiple HTTP verbs

Next.js API routes allow us to support multiple HTTP verbs for the same endpoint all in one file. The HTTP verbs that we want to support for a single API endpoint are specified in the request handler function.

Let's create a pages/api/users/index.js file to create an API route for all users. We want this endpoint to support GET requests to get all users and POST requests to create users. Let's add the following code within this file.

export default (req, res) => {
  const { method } = req;

  switch (method) {
    case 'GET':
      res.json({ method: 'GET', endpoint: 'Users' });
      break;
    case 'POST':
      res.json({ method: 'POST', endpoint: 'Users' });
      break;
    default:
      res.setHeader('Allow', ['GET', 'POST']);
      res.status(405).end(`Method ${method} Not Allowed`);
      break;
  }
}

The req.method value will tell us what HTTP verb was used to make the request. We can use a switch statement to support multiple HTTP verbs for the same endpoint. Any HTTP requests that are not GET or POST, requests will enter the default case and return a 405 Method Not Allowed error.

Visit http://localhost:3000/api/users in a web browser. You should see the following response.

{ "method": "GET", "endpoint": "Users" }

We can use an API platform like Postman or Insomnia to test POST, PUT, and DELETE requests to this API endpoint.

Testing the POST request will return the following response.

{
  "method": "POST",
  "endpoint": "Users"
}

Testing PUT and DELETE requests will return Method Not Allowed errors with a 405 Method Not Allowed response code.

Using API routes from the front-end

Let's create a users/[id].js page to retrieve a specific user when the page loads, and then mimic a save profile operation when the form on the page is submitted.

We will use client-side rendering within Next.js for this page. Client-side rendering is when the client makes requests for API data. The approach used in this example can also apply for pages that use server-side Rendering (SSR).

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';

export default function User() {
  const router = useRouter();
  const { id } = router.query;
  const [name, setName] = useState();

  // GET request to get a user
  useEffect(() => {
    // wait for the useRouter hook to asynchronously get the query id
    if (!id) {
      return;
    }

    const fetchUser = async () => {
      const response = await fetch(`/api/users/${id}`, {
        method: "GET",
        headers: {
          "Content-Type": "application/json"
        },
      });

      if (!response.ok) {
        throw new Error(`Error: ${response.status}`);
      }

      const user = await response.json();
      setName(user?.name);
    }

    fetchUser();
  }, [id]);

  // POST request to mimic the saving of a user
  const onSubmit = async (e) => {
    e.preventDefault();
    const response = await fetch("/api/users", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({}),
    });

    if (!response.ok) {
      throw new Error(`Error: ${response.status}`);
    }

    const data = await response.json();
    console.log('POST: ', data);
  };

  return (
    <div>
      <h1>User Form</h1>
      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="name">Name</label>
          <input 
            type="text" 
            id="name" 
            name="name" 
            value={name ?? ''} 
            onChange={(e) => setName(e.target.value)}
          />
        </div>
        <button type="submit">Submit</button>
      </form>
    </div>
  );
}

To retrieve a user by id when the page loads, we can use the React useEffect hook with the id as a dependency. The browser-supported Fetch API is used to make a GET request to /api/users/[id]. Async/await is used to wait for this asynchronous request to complete. Once it is completed, the user's name is saved to the component state using setName, and displayed within the input text box.

When the form is submitted, a POST request is made to the /api/users API route that we created earlier. To keep things simple, the response of the request is then logged to the console.

Conlusion

Next.js makes it fast and easy to build an API for your next project. A very good use case to get started with Next.js API routes is for the implementation of CRUD operations to handle form input.

API endpoints are created as Node.js serverless functions. You can build out your entire application's API with Next.js API routes. Go ahead and build something awesome with them!

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

You might also like

Vercel BotID: The Invisible Bot Protection You Needed cover image

Vercel BotID: The Invisible Bot Protection You Needed

Nowadays, bots do not act like “bots”. They can execute JavaScript, solve CAPTCHAs, and navigate as real users. Traditional defenses often fail to meet expectations or frustrate genuine users. That’s why Vercel created BotID, an invisible CAPTCHA that has real-time protections against sophisticated bots that help you protect your critical endpoints. In this blog post, we will explore why you should care about this new tool, how to set it up, its use cases, and some key considerations to take into account. We will be using Next.js for our examples, but please note that this tool is not tied to this framework alone; the only requirement is that your app is deployed and running on Vercel. Why Should You Care? Think about these scenarios: - Checkout flows are overwhelmed by scalpers - Signup forms inundated with fake registrations - API endpoints draining resources with malicious requests They all impact you and your users in a negative way. For example, when bots flood your checkout page, real customers are unable to complete their purchases, resulting in your business losing money and damaging customer trust. Fake signups clutter the app, slowing things down and making user data unreliable. When someone deliberately overloads your app’s API, it can crash or become unusable, making users angry and creating a significant issue for you, the owner. BotID automatically detects and filters bots attempting to perform any of the above actions without interfering with real users. How does it work? A lightweight first-party script quickly gathers a high set of browser & environment signals (this takes ~30ms, really fast so no worry about performance issues), packages them into an opaque token, and sends that token with protected requests via the rewritten challenge/proxy path + header; Vercel’s edge scores it, attaches a verdict, and checkBotId() function simply reads that verdict so your code can allow or block. We will see how this is implemented in a second! But first, let’s get started. Getting Started in Minutes 1. Install the SDK: ` 1. Configure redirects Wrap your next.config.ts with BotID’s helper. This sets up the right rewrites so BotID can do its job (and not get blocked by ad blockers, extensions, etc.): ` 2. Integrate the client on public-facing pages (where BotID runs checks): Declare which routes are protected so BotID can attach special headers when a real user triggers those routes. We need to create instrumentation-client.ts (place it in the root of your application or inside a src folder) and initialize BotID once: ` instrumentation-client.ts runs before the app hydrates, so it’s a perfect place for a global setup! If we have an inferior Next.js version than 15.3, then we would need to use a different approach. We need to render the React component inside the pages or layouts you want to protect, specifying the protected routes: ` 3. Verify requests on your server or API: ` - NOTE: checkBotId() will fail if the route wasn’t listed on the client, because the client is what attaches the special headers that let the edge classify the request! You’re all set - your routes are now protected! In development, checkBotId() function will always return isBot = false so you can build without friction. To disable this, you can override the options for development: ` What happens on a failed check? In our example above, if the check failed, we return a 403, but it is mostly up to you what to do in this case; the most common approaches for this scenario are: - Hard block with a 403 for obviously automated traffic (just what we did in the example above) - Soft fail (generic error/“try again”) when you want to be cautious. - Step-up (require login, email verification, or other business logic). Remember, although rare, false positives can occur, so it’s up to you to determine how you want to balance your fail strategy between security, UX, telemetry, and attacker behavior. checkBotId() So far, we have seen how to use the property isBot from checkBotId(), but there are a few more properties that you can leverage from it. There are: isHuman (boolean): true when BotID classifies the request as a real human session (i.e., a clear “pass”). BotID is designed to return an unambiguous yes/no, so you can gate actions easily. isBot (boolean): We already saw this one. It will be true when the request is classified as automated traffic. isVerifiedBot (boolean): Here comes a less obvious property. Vercel maintains and continuously updates a comprehensive directory of known legitimate bots from across the internet. This directory is regularly updated to include new legitimate services as they emerge. This could be helpful for allowlists or custom logic per bot. We will see an example in a sec. verifiedBotName? (string): The name for the specific verified bot (e.g., “claude-user”). verifiedBotCategory? (string): The type of the verified bot (e.g., “webhook”, “advertising”, “ai_assistant”). bypassed (boolean): it is true if the request skipped BotID check due to a configured Firewall bypass (custom or system). You could use this flag to avoid taking bot-based actions when you’ve explicitly bypassed protection. Handling Verified Bots - NOTE: Handling verified bots is available in botid@1.5.0 and above. It might be the case that you don’t want to block some verified bots because they are not causing damage to you or your users, as it can sometimes be the case for AI-related bots that fetch your site to give information to a user. We can use the properties related to verified bots from checkBotId() to handle these scenarios: ` Choosing your BotID mode When leveraging BotID, you can choose between 2 modes: - Basic Mode: Instant session-based protection, available for all Vercel plans. - Deep Analysis Mode: Enhanced Kasada-powered detection, only available for Pro and Enterprise plan users. Using this mode, you will leverage a more advanced detection and will block the hardest to catch bots To specify the mode you want, you must do so in both the client and the server. This is important because if either of the two does not match, the verification will fail! ` Conclusion Stop chasing bots - let BotID handle them for you! Bots are and will get smarter and more sophisticated. BotID gives you a simple way to push back without slowing your customers down. It is simple to install, customize, and use. Stronger protection equals fewer headaches. Add BotID, ship with confidence, and let the bots trample into a wall without knowing what’s going on....

Next.js + MongoDB Connection Storming cover image

Next.js + MongoDB Connection Storming

Building a Next.js application connected to MongoDB can feel like a match made in heaven. MongoDB stores all of its data as JSON objects, which don’t require transformation into JavaScript objects like relational SQL data does. However, when deploying your application to a serverless production environment such as Vercel, it is crucial to manage your database connections properly. If you encounter errors like these, you may be experiencing Connection Storming: * MongoServerSelectionError: connect ECONNREFUSED &lt;IP_ADDRESS>:&lt;PORT> * MongoNetworkError: failed to connect to server [&lt;hostname>:&lt;port>] on first connect * MongoTimeoutError: Server selection timed out after &lt;x> ms * MongoTopologyClosedError: Topology is closed, please connect * Mongo Atlas: Connections % of configured limit has gone above 80 Connection storming occurs when your application has to mount a connection to Mongo for every serverless function or API endpoint call. Vercel executes your application’s code in a highly concurrent and isolated fashion. So, if you create new database connections on each request, your app might quickly exceed the connection limit of your database. We can leverage Vercel’s fluid compute model to keep our database connection objects warm across function invocations. Traditional serverless architecture was designed for quick, stateless web app transactions. Now, especially with the rise of LLM-oriented applications built with Next.js, interactions with applications are becoming more sequential. We just need to ensure that we assign our MongoDB connection to a global variable. Protip: Use global variables Vercel’s fluid compute model means all memory, including global constants like a MongoDB client, stays initialized between requests as long as the instance remains active. By assigning your MongoDB client to a global constant, you avoid redundant setup work and reduce the overhead of cold starts. This enables a more efficient approach to reusing connections for your application’s MongoDB client. The example below demonstrates how to retrieve an array of users from the users collection in MongoDB and either return them through an API request to /api/users or render them as an HTML list at the /users route. To support this, we initialize a global clientPromise variable that maintains the MongoDB connection across warm serverless executions, avoiding re-initialization on every request. ` Using this database connection in your API route code is easy: ` You can also use this database connection in your server-side rendered React components. ` In serverless environments like Vercel, managing database connections efficiently is key to avoiding connection storming. By reusing global variables and understanding the serverless execution model, you can ensure your Next.js app remains stable and performant....

Introduction to Remix cover image

Introduction to Remix

What is Remix? Remix is a full stack web framework based on web fundamentals and modern UX. It was created by the Remix.run team, founded by Ryan Florence and Michael Jackson. They are the creators of React Router. Remix is a seamless server and browser runtime. It has no static site support, and always relies on a server. Remix aims to provide fast page load times and instant UI transitions. Remix is built on the Web Fetch API, which allows it to run anywhere. It can be deployed in a serverless environment or in a Node.js server environment. Remix features Fast data fetching Fetching data is so fast with Remix that there is no need for transitional spinners. Rather than fetching data via a series of requests from components, Remix will load data in parallel on the server. It will then send the browser an HTML document that contains the data, ready to be displayed. Making use of its own cache, Remix makes page reloads really fast. Remix will reload unchanged data from the cache, and only fetch new data. Forms Remix has a Form component, which is an enhanced HTML form component. Remix's Form component requires no onChange, onClick, or onSubmit events on the form or its fields. Contrary to traditional React forms, there is also no need for useState fields per form input. Remix's Form component will automatically do a POST request to the current page route with all the form's data submitted. It can be configured to do PUT and DELETE requests as well. To handle requests from a form, a simple action method is needed. Forms have traditionally been a point of frustration with React apps. Remix's approach to forms allows developers to create forms without having to write lines and lines of boilerplate code. Routing Similar to Next.js, Remix uses the file system to define page routes. Remix is built on top of React Router v6. This means that all the React Router APIs can be used in a Remix application. Anything that works with React Router will work within Remix. When navigating using a Link tag, the Outlet tag from React Router will automatically render the link's content on the page. This makes it easy to build a hierarchy of nested routes. Nested routes Nested routes allow Remix to make apps really fast. Remix will only load the nested routes that change. Remix will also only update the single nested component that was updated by some user interaction. Nested Routes also provide nested CSS styling. This allows CSS to be loaded on a per page basis. When a user navigates away from a certain page, that page's stylesheet is removed. Error handling When a route in a Remix app throws an error in its action method, loader, or component, it will be caught automatically. Remix won't try to render the component. It will render the route's ErrorBoundary instead. An ErrorBoundary is a special component that handles runtime errors. It's almost like a configurable try/catch block. If a given route has no ErrorBoundary, then the error that occured will bubble up to the routes above until it reaches the ErrorBoundary of the App component in the root.tsx file (assuming TypeScript is used). Error handling is built into Remix to make it easier to do. If an error is thrown on the client side or on the server side, the error boundary will be displayed instead of the default component. This graceful degradation improves the user experience. Project setup Creating a new Remix project is as easy as using the following command in a terminal window. ` You'll be asked to pick a folder name for your app. You'll also be asked where you want to deploy. Select *Remix App server*. > Remix can be deployed in several environments. The "Remix App Server" is a Node.js server based on Express. It's the simplest option to get up and running with Remix. You'll then be asked to choose between TypeScript or JavaScript. The last step will ask you if you want to run npm install. Say yes. Once installed, Remix can be run locally by using the following terminal commands. ` Once Remix is running, you can go to localhost:3000 in your browser to see if the default Remix installation worked. You should see the following. Project structure Let's explore the project structure that Remix created for us. - The public/ folder is for static assets such as images and fonts. - The app/ folder contains the Remix app's code. - The app/root.tsx file contains the root component for the app. - The app/entry.client.tsx file runs when the app loads in the browser. It hydrates React components. - The app/entry.server.tsx generates a HTTP response when rendering on the server. It will run when a request hits the server. Remix will handle loading the necessary data and we must handle the response. By default, this file is used to render the React app to a string/stream that is sent as a response to the client. - The app/routes/ folder is where *route modules* go. Remix uses the files in this folder to create the URL routes for the app based on the naming of these files. - The app/styles/ folder is where CSS goes. Remix supports route-based stylesheets. Stylesheets that are named after routes will be used for those routes. Nested routes can add their own stylesheets to the page. Remix automatically prefetches, loads, and unloads stylesheets based on the current route. - The remix.config.js file is used to set various configuration options for Remix. > Any file ending with .client.* or .server.* is only available on the client or the server. Demos The default installation of Remix comes with demos that allow us to see some of Remix's defining characteristics in action. Forms and actions Head over to http://localhost:3000 in your browser. Click on the Actions link under the Demos In This App heading. This will give you a chance to try out Remix forms and their corresponding action methods. To view the code for this demo, take a look at the app/routes/demos/actions.tsx file. ` The action function above is a server-only function to handle data mutations. If a non-GET request is made to the page's route (POST, PUT, PATCH, DELETE), then the action function is called before the loader function. Actions are very similar to loaders. The only difference is when they are called. CSS and Nested routes Click on the Remix logo to go back to the welcome page. Now, click on Nested Routes, CSS loading/unloading to see how Remix allows certain CSS rules to only be included on specific routes and their children. To view the code for this demo, take a look at the files in theapp/routes/demos/about/ folder, as well as the app/styles/demos/about.css file for the route-based CSS. Linking to a nested page is as simple as: ` Linking back to a parent route from a nested route is as simple as: ` Routing and Error Boundaries Click on the Remix logo to go back to the welcome page. Now, click on URL Params and Error Boundaries to see how routing and error boundaries work in Remix. To view the code for this demo, take a look at theapp/routes/demos/params/$id.tsx file. Loader This route defines a *loader* function that is called on the server before providing data to the route. When a route needs to fetch data from the server, Remix uses the loader function to handle that responsibility. The loader function aims to simplify the task of loading data into components. Contrary to Next.js, API routes are not needed to fetch data for route components in Remix. Here is the loader function above the ParamDemo component. ` The ParamDemo component uses a useLoaderData hook to get the data from the loader function. In this case, that data is the id value of the URL parameter that is passed to the route. Error Boundary ` An ErrorBoundary component is rendered when an error occurs anywhere on the route. Error boundaries are helpful for uncaught exceptions that we don't expect to happen. Clicking on the *This one will throw an error* link will trigger the ErrorBoundary. Catch Boundary ` A CatchBoundary component is rendered when the loader throws a Response. The status code of the response can be checked from within the CatchBoundary by using the useCatch hook. Clicking on the *This will be a 404* and *And this will be 401 Unauthorized* links will trigger the CatchBoundary. Conclusion Remix is backed by some of the most talented engineers in the React community. Version 1.0 of this full stack React-based web framework was just released on November 22, 2021. It's now released under the MIT license and is open source, making it free to use. For more Remix tutorials, check out the following: - Developer blog - Jokes App...

AI Is Speeding Up Development. But Where Are the New Bottlenecks? cover image

AI Is Speeding Up Development. But Where Are the New Bottlenecks?

AI is accelerating development, but it’s also exposing everything else that’s broken. At the Leadership Exchange, leaders unpacked how AI is reshaping the SDLC and what organizations need to address beyond just coding to make adoption successful. Moderated by Rob Ocel, VP of Innovation at This Dot Labs, the panel featured Itai Gerchikov at Anthropic and Harald Kirschner, Principal Product Manager for GitHub Copilot & VS Code at Microsoft. Panelists explored the current state of AI adoption across the software development lifecycle and shared practical insights into how organizations can effectively integrate AI tools. Panelists discussed how companies are investing in AI tools, skills, and managed competency programs to support developers. While AI can dramatically accelerate coding, the panel emphasized that adoption affects every stage of the SDLC. Bottlenecks now appear in testing, DevOps, product delivery, and marketing as AI speeds up development. Organizations that address technical debt and process inefficiencies are better positioned to extract maximum value from AI tools. The conversation also focused on opportunities and risks. Security, governance, and workforce education were highlighted as critical factors for adoption. Panelists stressed that AI initiatives should be aligned with broader business goals rather than pursued in isolation. They noted that companies experimenting at the cutting edge need to consider organizational readiness just as carefully as technical capabilities. Panelists also explored how leading organizations are navigating the early stages of adoption. Those ahead of the curve are using structured experimentation, prioritizing process improvements, and continuously evaluating outcomes to refine their AI strategies. Learning from these early adopters allows other organizations to anticipate emerging trends and prepare for the next phase of AI adoption rather than simply replicating past approaches. Key Takeaways - Investing in AI skills and tools should be done thoughtfully, with clear alignment to business objectives. - Examining the full SDLC helps identify bottlenecks that AI may accelerate or expose. - Organizations can gain a competitive advantage by learning from early adopters and planning for where AI adoption is heading. AI adoption is not just a technical initiative; it is a strategic transformation that requires attention to people, process, and technology. Organizations that balance innovation with operational discipline will be best positioned to capture the full potential of AI across the software lifecycle. Seeing similar challenges in your own SDLC? Let’s compare notes. Join us at an upcoming Leadership Exchange or reach out to continue the conversation. Tracy can be reached at tlee@thisdot.co....

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co