Published at

Ensuring Type Safety in Next.js Routing

Ensuring Type Safety in Next.js Routing

Learn type-safe routing in Next.js for robust, maintainable applications. Start with setup and code examples for creating type-safe routes and API endpoints

Authors
Table of Contents

Type Safe Routing in Next.js

Last week, I explored an open-source project called declarative routing. This project facilitates the implementation of a type-safe routing system within Next.js applications. I’m interested in writing a short article about it and the insights I gained from building a small project that utilized declarative routing.

what is declarative-routing?

While type-safe routing offers many benefits, it can introduce challenges when working with URLs. Consider the following example:

<Link to={/post/${post.id}}>post</Link>

This approach might lead to issues if the URL structure or parameters change later.

Declarative routing offers a solution by providing components specifically typed into routes. These components handle URL construction automatically. For instance, with a component named PostDetail.Link, you could write the code as:

<PostDetail.Link id={post.id}>post</PostDetail.Link>

This approach ensures that any updates to the route or its parameters are reflected consistently throughout your application, wherever the PostDetail.Link component is used.

Build a Small Next.js App with Declarative Routing

The source code for this project is publicly available on GitHub to get started with Declarative Routing: run the following command:

npx declarative-routing init

This will create a directory called @/routes, which you can use to access websites and submit API queries. In the routes directory, it also creates a README.md file with instructions on how to utilize the system.

To keep the @/routes directory synchronized with your application’s routes, be sure to run the following command whenever you modify existing files or introduce new routes.**

npx declarative-routing build

This will generate info.ts files in the same directory as page.tsx or route.ts Subsequently, it will import these .info.ts files into @/routes/index.ts, allowing you to import all the routes from @/routes and utilize them within your application

The project appears to have two routes:

  • Home(/): leads to a list of posts.
  • PostID(/posts/<postID>): Enables viewing individual posts.

Additionally, there is one API endpoint located at src/app/api/posts/route.ts, which is used to retrieve lists of posts. The posts being used are located at src/db/db.ts, which exports an array of dummy data. We are sending that data from our API route.

Homepage

here is the code for the homepage

"use client";
import EachPost from "@/components/posts";
import { getApiPosts } from "@/routes";

import React, { useEffect, useState } from "react";

function Post() {
  const [posts, setPosts] = useState<Awaited<ReturnType<typeof getApiPosts>>>(
    [],
  );

  const [limit, setLimit] = useState<number | null>(null);

  useEffect(() => {
    getApiPosts({}, { limit }).then(setPosts);
  }, [limit]);

  return (
    <div>
      <div>
        <h1 className="font-bold text-2xl ">API Route Example</h1>
      </div>
      <div>
        <input
          type="number"
          placeholder="Add limit"
          className="p-3 rounded-xl border border-black"
          onChange={(e) => setLimit(Number(e.target.value))}
        />
      </div>
      <div>
        {posts?.map(({ id, title }) => (
          <EachPost id={Number(id)} title={title} key={id} />
        ))}
      </div>
    </div>
  );
}

export default Post;

Let me explain the code above. We import getApiPosts from @/routes which is auto-generated by calling declarative-routing build. This function retrieves a list of posts from /api/posts and updates the state variable.

At this time, you may be wondering why not simply use a server component and invoke getApiPosts at the top level of the component? Well, you are right, but there is an issue.

if you do that, you will get an error Failed to parse URL /api/posts

When calling getApiPosts we are simply making a fetch call to /api/posts.

In server components, you need to specify an absolute URL. In a web browser, when you say fetch(‘/api/posts’), the browser assumes that it’s relative to the document base URL/origin.

For more information, check out this issue.

How Does getApiPosts function work?

if you see @routes/index.ts below

// Automatically generated by declarative-routing; do NOT edit
import { z } from "zod";
import { makeGetRoute, makeRoute } from "./makeRoute";

const defaultInfo = {
  search: z.object({}),
};

import * as HomeRoute from "@/app/page.info";
import * as ApiPostsRoute from "@/app/api/posts/route.info";
import * as PostsPostIDRoute from "@/app/posts/[postID]/page.info";

export const Home = makeRoute("/", {
  ...defaultInfo,
  ...HomeRoute.Route,
});
export const PostsPostID = makeRoute("/posts/[postID]", {
  ...defaultInfo,
  ...PostsPostIDRoute.Route,
});

export const getApiPosts = makeGetRoute(
  "/api/posts",
  {
    ...defaultInfo,
    ...ApiPostsRoute.Route,
  },
  ApiPostsRoute.GET,
);

The makeGetRoute function is a helper function that generates these type-safe get functions. It takes three arguments:

  1. The path for which the function should be generated. In the case of getApiPosts, this would be “/api/posts.”.
  2. An object that contains the schema for the route’s parameters and query parameters. This is typically generated from a .info.ts file that is associated with the route.
  3. An optional object that represents the schema for the response data expected from the endpoint. This ensures that the data returned from the endpoint adheres to the expected structure, further enhancing the type safety of your application.

Creation of page routes with Declarative Routing

  • makeRoute: This function is used to define a page route. It takes the path of the route as a string and an info object that contains the name of the route, as well as the Zod schemas for the route parameters and search parameters. This function ensures that any access to the specified route adheres to the expected structure, enhancing the type safety of your application. Here is an example usage:
export const Home = makeRoute("/", {
  ...defaultInfo,
  ...HomeRoute.Route,
});

In the example above, Home is a type-safe function that constructs the URL for the home page. It ensures that any access to the home page route adheres to the expected structure, further enhancing the type safety of your application.

Creation of API routes with Declarative Routing

In declarative routing, API routes are defined using makeGetRoute, makePostRoute, makePutRoute, and makeDeleteRoute functions. These functions facilitate the creation of type-safe functions for API endpoints in your Next.js application.

  • makePostRoute: This function is used to create a type-safe function for POST requests to an API endpoint. It takes the path of the route as a string, an info object that contains the name of the route, and Zod schemas for the route parameters and search parameters. It also takes a secondary object containing the body schema. The function ensures that any POST request to the specified route adheres to the expected type structure, enhancing the type safety of your application.
  • makePutRoute: Similar to makePostRoute, this function is used to create a type-safe function for PUT requests to an API endpoint. It takes the same arguments as makePostRoute, including the path, info object, and body schema. The use of this function ensures consistency and type safety when making PUT requests in your application.
  • makeDeleteRoute: This function is used for DELETE requests. It takes the path of the route as a string, an info object containing the name of the route, and the Zod schemas for the route parameters and search parameters. It does not require a body schema, as DELETE requests typically do not include a body.

The getApiPosts function accepts three parameters based on the following object located at src/app/api/route.info.ts:

export const Route = {
  name: "ApiPosts",
  params: z.object({}),
  search: z.object({ limit: z.number().nullable() }),
};

Parameters:

  • The first argument corresponds to the Route.params property, which is an empty object in this case.
  • The second argument aligns with the Route.search property. It accepts an optional limit parameter of type number. If you omit the limit parameter, an error will occur. Argument of type '' is not assignable to parameter of type ’{ limit: number | null; }’.
  • The third argument (optional) allows you to pass additional options like headers and methods, similar to the options argument in the Fetch API.

After retrieving the posts from the server, we update the state variable. The posts are then displayed on the page, and the EachPost component handles rendering each post’s title

import { PostsPostID } from "@/routes";
import React from "react";

function EachPost({ id, title }: { id: number; title: string }) {
  return (
    <div key={id}>
      <PostsPostID.Link postID={String(id)}>
        <div className="text-xl text-blue-700 hover:text-blue-500 hover:underline">
          title : {title}
        </div>
      </PostsPostID.Link>
    </div>
  );
}

export default EachPost;

The EachPost component accepts two properties: postId and title. It renders a link to /posts/<postId> using the PostsPostID.Link component. As previously mentioned, this component is auto-generated and functions similarly to the following code:

<Link href={`/posts/${id}`}>{title}</Link>

However, the advantage of using PostsPostID.Link is that it offers type safety. This means that if you forget to pass the postID or pass it in the wrong type, your code will not compile, thus preventing potential runtime errors. This is a significant benefit of declarative routing in Next.js applications.

The open-source project, declarative routing, provides a type-safe routing system within Next.js applications. It offers components specifically typed into routes, handles URL construction automatically, and ensures consistent updates throughout the application.

Sharing is caring!