iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💭

Building an Article Posting Site with Next.js 13 App Directory

に公開
12

A new feature called App Router has been added since Next.js 13. This is a layout system different from the traditional pages directory. App Router has the following characteristics:

  • Routing: In the pages directory, page routing was determined by the file name. For example, a file named pages/about.js corresponds to the path /about. In App Router, the file corresponding to routing has a fixed name called page.js. The file corresponding to the path /about would be named app/about/page.js. In addition to page.js, there are various special files such as layout.js for shared layouts and loading.js for displaying loading UI.
  • Rendering: Components within the App Router are treated as Server Components by default. If you want to treat them as Client Components, you need to declare "use client" at the top of the file.
  • Data Fetching: Traditional getStaticProps and getServerSideProps cannot be used in App Router. Instead, you can fetch data using async/await in Server Components. Also, the fetch API is used to leverage caching and request deduplication when fetching data.
  • Caching: When fetching data using the fetch API, Next.js HTTP caching is enabled by default. Also, due to client-side caching, extra requests do not occur during client-side navigation.

By using App Router, you can expect not only an intuitive layout system but also performance improvements. In this article, let's build a simple article posting site using the app directory and experience the features of the new functionality.

The completed code can be found in the following repository:

https://github.com/azukiazusa1/nextjs-app-dir-example/tree/complete

Preparation of the Development Environment

First, create a Next.js application. If you clone from the azukiazusa1/nextjs-app-dir-example repository, the backend API will already be prepared.

git clone https://github.com/azukiazusa1/nextjs-app-dir-example.git

If you want to create it yourself, run the following command:

npx create-next-app@latest

Install the packages and start the development environment with the following commands:

npm install
npm run dev

When you access http://localhost:3000/, the following screen will be displayed.

Default browser screen displayed when starting the development environment with Next.js

Overview of App Router

First, the following files exist in the App Router's initial state:

  • page.tsx: A file that defines the UI corresponding to the routing.
  • layout.tsx: The root layout of the application. In addition to the navigation header used across all pages, it sets the <html> and <body> tags.

Editing the page.tsx file

Let's edit the page.tsx file and confirm that the display changes.

app/page.tsx
export default function Home() {
  return (
    <div>
      <h1>新着記事</h1>
      <ul>
        <li>記事1</li>
        <li>記事2</li>
        <li>記事3</li>
      </ul>
    </div>
  )
}

After editing the file, access http://localhost:3000/ to see that the display has changed as follows.

Changes in app/page.tsx reflected in the browser

Next, let's create a page that displays article details when accessing a path like /articles/{slug}. Since the structure of the App Router is mapped to the URL path, create a directory called app/articles/[slug].

mkdir app/articles/[slug]

Create the file responsible for the UI of the created URL path with the name page.tsx.

touch app/articles/[slug]/page.tsx

Let's edit the app/articles/[slug]/page.tsx file as follows. To get the dynamic path value (slug), it receives params from the arguments.

app/articles/[slug]/page.tsx
export default function Article({ params }: { params: { slug: string } }) {
  return (
    <div>
      <h1>記事の詳細</h1>
      <p>記事のスラッグ: {params.slug}</p>
    </div>
  );
}

Accessing http://localhost:3000/articles/next-js-app-dir-tutorial confirms that the content of the file edited above is displayed.

Changes in app/articles/[slug]/page.tsx reflected in the browser

Editing the layout.tsx file

Let's also edit the Layout. The app/layout.tsx file is called the Root Layout. The Root Layout is applied to all pages. Since Next.js does not automatically generate <html> or <body> tags, they must be defined in the app/layout.tsx file.

app/layout.tsx
import Link from "next/link";

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
  themeColor: "#ffffff",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head />
      <body>
        <header>
          <h1>
            <Link href="/">ブログ</Link>
          </h1>
          <Link href="/articles/new">記事を書く</Link>
        </header>
        {children}
        <footer>
          <small>© 2023 azukiazusa</small>
        </footer>
      </body>
    </html>
  );
}

Access http://localhost:3000/ and http://localhost:3000/articles/next-js-app-dir-tutorial to confirm that the same layout is applied to both pages.

Layout provided to the / path
Layout provided to the articles/next-js-app-dir-tutorial path

Introducing Chakra UI

The application created so far is a bit plain, so we will introduce Chakra UI for styling. Chakra UI is a UI library available for React. It is characterized by offering many UI components and having excellent customizability.

First, install Chakra UI.

npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6

Setting up the Provider

To use Chakra UI, you need to set ChakraProvider at the root of the application. In App Router, app/layout.tsx becomes the root element. Let's set ChakraProvider here.

app/layout.tsx
  import Link from "next/link";
+ import { ChakraProvider } from "@chakra-ui/react";

  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      <html lang="ja">
        <head />
        <body>
+         <ChakraProvider>
            <header>
              {/* ... */}
+         </ChakraProvider>
        </body>
      </html>
    );
  }

However, a compilation error will occur as it is.

A compilation error message saying 'Failed to compile' is displayed

This is because components within the App Router are treated as React Server Components by default.

Distinguishing Between Server Components and Client Components

Server Components are a mechanism for rendering components only on the server side. Server Components have the following benefits:

  • No JavaScript is sent to the client.
  • Access to databases or GraphQL endpoints can be performed closer to the source.

As a result, you can benefit from faster initial page loads and smaller client-side JavaScript bundle sizes. Generally, you should use them as Server Components by default.

However, Server Components have some limitations:

  • They do not hold state, so hooks like useState or Context cannot be used.
  • Lifecycle hooks like useEffect cannot be used.
  • APIs available only in the browser, such as localStorage, cannot be used.
  • Event handlers like onClick or onChange cannot be used.

Components that manage state using useState or perform interactive actions using event handlers must be treated as Client Components. To treat a component within the App Router as a Client Component, declare "use client" at the top of the file.

Since Server Components and Client Components complement each other's strengths and weaknesses, it is necessary to use them appropriately.

<ChakraProvider> uses useState internally, so it cannot be treated as a Server Component. This is the cause of the compilation error.

To resolve this issue, you need to configure <ChakraProvider> to be treated as a Client Component instead of a Server Component.

To treat third-party components like <ChakraProvider> as Client Components, wrap them in a file that declares "use client". Let's create an app/Provider.tsx file.

app/Provider.tsx
"use client";

import { ChakraProvider } from "@chakra-ui/react";

export default function Provider({ children }: { children: React.ReactNode }) {
  return <ChakraProvider>{children}</ChakraProvider>;
}

Then, replace <ChakraProvider> with <Provider> in app/layout.tsx.

app/layout.tsx
  import Link from "next/link";
- import { ChakraProvider } from "@chakra-ui/react";
+ import Provider from "./Provider";

  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      <html lang="ja">
        <head />
        <body>
-         <ChakraProvider>
+         <Provider>
            <header>
              {/* ... */}
-         </ChakraProvider>
+         </Provider>
        </body>
      </html>
    );
  }

This resolves the compilation error, and the Next.js application should now start normally.

Since all Chakra UI components depend on <ChakraProvider>, they only work in Client Components. Therefore, when using UI components, you must wrap them and declare "use client".

Declaring "use client" every time you use a Chakra UI component can be tedious. Let's export Chakra UI components together in app/common/components/index.tsx so they can be used as Client Components.

app/common/components/index.tsx
"use client";
export * from "@chakra-ui/react";

Now, when you want to use a Chakra UI component, you can write:

import { Button } from "./common/components";

This way, you don't have to worry about "use client".

Creating the Header Component

Now, let's create a common header component using Chakra UI. In App Router, unlike the traditional pages directory, you can freely place files as long as you don't use special filenames like page.tsx. Since we want to place the header component near the layout.tsx file, we will create Header.tsx under the App Router.

app/Header.tsx
import { Box, Flex, Heading, Button } from "./common/components";
import NextLink from "next/link";

export default function Header() {
  return (
    <Box as="header">
      <Flex
        bg="white"
        color="gray.600"
        minH={"60px"}
        py={{ base: 2 }}
        px={{ base: 4 }}
        borderBottom={1}
        borderStyle="solid"
        borderColor="gray.200"
        align="center"
      >
        <Flex flex={1} justify="space-between" maxW="5xl" mx="auto">
          <Heading as="h1" size="lg">
            <NextLink href="/">Blog App</NextLink>
          </Heading>
          <Button
            as={NextLink}
            fontSize="sm"
            fontWeight={600}
            color="white"
            bg="orange.400"
            href="/articles/new"
            _hover={{
              bg: "orange.300",
            }}
          >
            記事を書く
          </Button>
        </Flex>
      </Flex>
    </Box>
  );
}

Similarly, create app/Main.tsx and app/Footer.tsx.

app/Main.tsx
import { Container } from "./common/components";

export default function Main({ children }: { children: React.ReactNode }) {
  return (
    <Container
      as="main"
      maxW="container.lg"
      my="4"
      minH="calc(100vh - 115px - 2rem)"
    >
      {children}
    </Container>
  );
}
app/Footer.tsx
import { Container, Box, Text } from "./common/components";

export default function Footer() {
  return (
    <Box bg="gray.50" color="gray.700" as="footer">
      <Container maxW="5xl" py={4}>
        <Text as="small">© 2023 azukiazusa</Text>
      </Container>
    </Box>
  );
}

Then, place <Header>, <Main>, and <Footer> in app/layout.tsx.

app/layout.tsx
  import Link from "next/link";
  import Provider from "./Provider";
+ import Header from "./Header";
+ import Main from "./Main";
+ import Footer from "./Footer";

  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      <html lang="ja">
        <head />
        <body>
          <Provider>  
+           <Header />
+           <Main>{children}</Main>
+           <Footer />
          </Provider>
        </body>
      </html>
    );
  }

Now the basic layout is complete. Let's confirm that it's displayed as expected.

Layout after applying Chakra UI

Displaying the List of Articles

Let's fetch the list of articles from the API and display them on the top page. The API is already prepared in the pages/api/ directory.

In Next.js, data fetching is commonly performed on the server side. However, getServerSideProps and getStaticProps provided in traditional Next.js are not supported in App Router. Instead, fetching data from the API is done using async/await within a Server Component. Server Components also function as asynchronous components.

Data fetching in App Router fundamentally uses the Fetch API. While the Fetch API is a native feature of Web APIs, it is extended as follows when used in Next.js:

  • Automatically deduplicates requests
  • Requests called before dynamic functions (cookies(), headers(), useSearchParams()) are HTTP cached by default
  • Supports revalidate as a proprietary caching strategy

While data fetching can also be performed in Client Components, it is recommended to always do it within Server Components for the following reasons:

  • Direct access to backend resources such as databases
  • Sensitive information like access tokens is not exposed to the client
  • Data fetching and rendering happen in the same environment, reducing client-server communication and work on the client's main thread
  • Multiple data fetches can be performed in a single request
  • Fetching data closer to the data source reduces latency

Now, let's actually perform data fetching in a Server Component. First, we'll prepare the type definitions. Create an app/types.ts file.

app/types.ts
export type Article = {
  id: number;
  title: string;
  content: string;
  slug: string;
  createdAt: string;
  updatedAt: string;
};

export type Comment = {
  id: number;
  body: string;
  articleId: number;
  createdAt: string;
  updatedAt: string;
  author: Author;
};

export type Author = {
  name: string;
  avatarUrl: string;
};

In app/page.tsx, fetch the list of articles from http://localhost:3000/api/articles.

app/page.tsx
import type { Article } from "./types";

async function getArticles() {
  const res = await fetch("http://localhost:3000/api/articles");

  // Error handling is recommended
  if (!res.ok) {
    throw new Error("Failed to fetch articles");
  }

  const data = await res.json();
  return data.articles as Article[];
}

export default async function Home() {
  const articles = await getArticles();

  return (
    <div>
      <h1>新着記事</h1>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  );
}

The logic inside getArticles is the same as the typical usage of fetch. If an exception is thrown within the component, an error screen will be displayed by error.tsx, which will be described later.

By using async/await in the Home component, you can fetch and use data in a natural flow.

Data Caching

Let's also consider caching for data fetching. By default, using fetch automatically caches the data after it is fetched. This means the fetch options are set to { cache: "force-cache" } by default. The force-cache option works similarly to getStaticProps.

Since we are fetching a list of new articles here, data updates might occur frequently. Therefore, we will configure it to perform a request every time instead of caching the data.

To fetch new data every time fetch is executed, set cache: "no-store". no-store works similarly to getServerSideProps.

app/page.tsx
const res = await fetch("http://localhost:3000/api/articles", {
  cache: "no-store",
});

Loading UI

A delay of 1500ms has been set for fetching the list of new articles. It is not user-friendly for nothing to be displayed during that time, including the header section which is unrelated to the data fetching. Therefore, let's try displaying a loading UI while data is being fetched.

In Next.js 13, a special file called loading.tsx within the App Router handles the role of displaying the loading UI. loading.tsx is displayed while data is being fetched on the server (i.e., until the Server Component's Promise is resolved), and it shows the new content once rendering is complete. This behavior is the same as the fallback in Suspense.

The concept looks something like this:

<html lang="ja">
  <head />
  <body>
    <Provider>
      <Header />
      <Main>
        <Suspense fallback={<Loading />}>
          {/* page.tsx content is inserted into children */}
          {children}
        </Suspense>
      </Main>
      <Footer />
    </Provider>
  </body>
</html>

Create app/loading.tsx as follows:

app/loading.tsx
import { Box, Spinner } from "./common/components";

export default function Loading() {
  return (
    <Box justifyContent="center" display="flex">
      <Spinner color="orange.400" size="xl" />
    </Box>
  );
}

With loading.tsx, the loading UI is now displayed until the list of articles is fetched. Since loading.tsx is placed to wrap the page.tsx in the same directory, layouts such as the header are displayed immediately.

Loading UI is being displayed

Error Handling

If an exception is thrown within a Server Component, the content of error.tsx is displayed. error.tsx wraps the page.tsx file in the same directory with an Error Boundary.

The error.tsx file works conceptually like this:

<html lang="ja">
  <head />
  <body>
    <Provider>
      <Header />
      <Main>
        <ErrorBoundary fallback={<Error />}>
          {/* page.tsx content is inserted into children */}
          {children}
        </Suspense>
      </Main>
      <Footer />
    </Provider>
  </body>
</html>

The Error component receives the following Props:

  • error: The thrown exception object
  • reset: A function to re-render the component where the exception occurred

Also, error.tsx must always be a Client Component.

app/error.tsx
"use client"; // Error components must be Client components

import { useEffect } from "react";
import { Heading, Button } from "./common/components";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div>
      <Heading mb={4}>An unexpected error occurred.</Heading>
      <Button onClick={() => reset()}>Try again</Button>
    </div>
  );
}

Let's check the error handling behavior by intentionally causing an exception in app/page.tsx.

app/page.tsx
  async function getArticles() {
    const res = await fetch("http://localhost:3000/api/articles", {
      cache: "no-store",
    });

+   throw new Error("Failed to fetch articles");

UI when an error occurs

ArticleList Component

Finally, let's create a component responsible for displaying articles to tidy up the appearance. First, create the ArticleCard component.

app/components/ArticleCard.tsx
import {
  Card,
  CardHeader,
  CardBody,
  CardFooter,
  Heading,
  Text,
} from "./common/components";
import NextLink from "next/link";
import { Article } from "./types";

export default function ArticleCard({ article }: { article: Article }) {
  const formattedDate = new Date(article.createdAt).toLocaleDateString(
    "ja-JP",
    {
      year: "numeric",
      month: "long",
      day: "numeric",
    }
  );
  return (
    <Card
      as={"li"}
      _hover={{
        boxShadow: "xl",
      }}
      minW="100%"
    >
      <NextLink href={`/articles/${article.slug}`}>
        <CardHeader>
          <Heading size="md">{article.title}</Heading>
        </CardHeader>
        <CardBody>
          <Text>{article.content.substring(0, 200)}...</Text>
        </CardBody>
        <CardFooter>
          <Text fontSize="sm" color="gray.600">
            {formattedDate}
          </Text>
        </CardFooter>
      </NextLink>
    </Card>
  );
}

Create the ArticleList component to display the list of article cards.

app/components/ArticleList.tsx
import { VStack } from "./common/components";
import ArticleCard from "./ArticleCard";
import { Article } from "./types";

export default function ArticleList({ articles }: { articles: Article[] }) {
  return (
    <VStack spacing={4} as="ul">
      {articles.map((article) => (
        <ArticleCard key={article.id} article={article} />
      ))}
    </VStack>
  );
}

Incorporate the ArticleList component into app/page.tsx.

app/page.tsx
import ArticleList from "./ArticleList";
import { Heading } from "./common/components";

// ...

export default async function Home() {
  const articles = await getArticles();

  return (
    <div>
      <Heading as="h1" mb={4">
        新着記事
      </Heading>
      <ArticleList articles={articles} />
    </div>
  );
}

Accessing http://localhost:3000 should display the list of articles as follows.

Article list page rendered by ArticleList component

Article Detail Page

Let's create the article detail page. Here, we will implement functionality to fetch and display the body of the article along with comments for that article. A specific article is fetched from api/articles/{slug}, and comments for the article are fetched from api/articles/{slug}/comments.

Let's also consider the caching strategy for each. Since the article body is assumed not to be updated frequently, it can be cached for a certain period. The cache lifetime can be set by specifying next.revalidate in the fetch options. This is a feature similar to the traditional ISR.

On the other hand, it would be unnatural if comments are not reflected immediately after posting, so it is better not to use caching. Similar to fetching the list of articles, specify cache: "no-store" in the fetch options.

app/articles/[slug]/page.tsx
import { notFound } from "next/navigation";
import { Article, Comment } from "../../types";

const getArticle = async (slug: string) => {
  const res = await fetch(`http://localhost:3000/api/articles/${slug}`, {
    next: { revalidate: 60 },
  });

  if (res.status === 404) {
    // Calling the notFound function displays not-found.tsx
    notFound();
  }

  if (!res.ok) {
    throw new Error("Failed to fetch article");
  }

  const data = await res.json();
  return data as Article;
};

const getComments = async (slug: string) => {
  const res = await fetch(
    `http://localhost:3000/api/articles/${slug}/comments`,
    {
      cache: "no-store",
    }
  );

  if (!res.ok) {
    throw new Error("Failed to fetch comments");
  }

  const data = await res.json();
  return data as Comment[];
};

If the API returns a 404 when fetching an article, the notFound function from next/navigation is called. When this function is called, the not-found.tsx in the nearest directory is displayed.

Let's also create not-found.tsx.

app/pages/articles/not-found.tsx
import { Heading, Button } from "../../common/components";
import NextLink from "next/link";

export default function NotFound() {
  return (
    <div>
      <Heading mb={4}>The article you are looking for could not be found.</Heading>
      <Button as={NextLink} href="/">
        Back to Top
      </Button>
    </div>
  );
}

When accessing the URL of a non-existent article, it is displayed as follows:

Display when accessing the URL of a non-existent article

Let's return to displaying the article details. Call getArticle and getComments within the component to display the fetched data. When calling multiple APIs that have no dependencies on each other, it is recommended to use Promise.all so that the processes run in parallel.

app/articles/[slug]/page.tsx
export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const articlePromise = getArticle(params.slug);
  const commentsPromise = getComments(params.slug);

  const [article, comments] = await Promise.all([
    articlePromise,
    commentsPromise,
  ]);

  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.content}</p>
      <h2>Comments</h2>
      <ul>
        {comments.map((comment) => (
          <li key={comment.id}>{comment.body}</li>
        ))}
      </ul>
    </div>
  );
}

Now, when you visit the article detail page, the article content and the list of comments will be displayed. However, there is one issue: it takes too long to display the article.

A delay of 1000ms is set for fetching the article, and 3000ms for the list of comments. If only viewing the article body, it should originally be displayable in 1000ms. However, because Promise.all also waits for the completion of fetching the comment list, it takes 3000ms to display the article content.

Loading indicator displayed until the article details are shown

The purpose of a user visiting the article detail page is to view the article body, and the list of comments is just supplementary information. Therefore, it is not desirable to wait for the completion of the comment list retrieval.

So, let's try using streaming for fetching the comment list. By using streaming, you can display the article body the moment the article retrieval is complete, without waiting for the comment list retrieval to finish.

Fetching Comments with Streaming

Streaming breaks the page's HTML into small chunks and sends them progressively to the client. This allows the page to start displaying parts of itself without waiting for all data to be fetched.

To control where streaming occurs, wrap the asynchronous component with <Suspense>. Split the part that fetches the comment list into a separate component and wrap it with <Suspense> as follows:

app/articles/[slug]/page.tsx
export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const articlePromise = getArticle(params.slug);
  const commentPromise = getComments(params.slug);

  const article = await articlePromise;

  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.content}</p>
      <h2>Comments</h2>
      <Suspense fallback={<div>Loading comments...</div>}>
        {/* @ts-expect-error Currently, TypeScript reports a type error if JSX returns a Promise, but this will be resolved in the future */}
        <Comments commentPromise={commentPromise} />
      </Suspense>
    </div>
  );
}

async function Comments({
  commentPromise,
}: {
  commentPromise: Promise<Comment[]>;
}) {
  const comments = await commentPromise;
  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.content}</li>
      ))}
    </ul>
  );
}

Now let's check the behavior. After about 1000ms, the article body is displayed, and during that time, "Loading comments" is shown for the comment list. Then, after another 2000ms, the list of comments is displayed.

Fetching the comment list with streaming

<head> Tag

On the article detail page, you'll want to set the article title in the <title> tag for SEO purposes. To set <head> tags for each route, export a metadata object or a generateMetadata function from the page.tsx or layout.tsx file.
The contents of the configured metadata will be inserted into the <head /> tag of the root layout.

  • metadata object: Set <head> tag contents statically
  • generateMetadata function: Set <head> tag contents dynamically

The generateMetadata function receives params as an argument and can dynamically fetch values using async/await to set <head> in an object format. The second argument, parent, allows you to reference metadata set in parent directories.

app/pages/articles/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
  parent?: ResolvingMetadata;
}): Promise<Metadata> {
  const article = await getArticle(params.slug);
  return {
    title: article?.title,
    description: article?.content,
  };
}

While it might seem inefficient because it sends the same request as the ArticleDetail component, Next.js automatically deduplicates requests using fetch, so it doesn't impact performance.

Metadata is evaluated in order from the segment closest to the root directory to the segment containing the page.tsx file. For example, in app/layout.tsx, it is written as follows:

app/layout.tsx
export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
  themeColor: "#ffffff",
}

The title and description keys overlap with the metadata set in app/pages/articles/[slug]/page.tsx. In this case, the metadata set in the page.tsx closest to the page takes precedence, so the title and description for the article detail page will be the article's title and body. Furthermore, since themeColor is only set in app/layout.tsx, its content is inherited as is.

Styling

Finally, we apply styling using Chakra UI. Create the following components:

  • app/articles/[slug]/ArticleContent.tsx: Component that displays the article title and body
  • app/articles/[slug]/Comments.tsx: Component that displays the list of comments
  • app/articles/[slug]/LoadingComments.tsx: Component displayed while comments are loading
app/articles/[slug]/ArticleContent.tsx
import {
  Card,
  CardHeader,
  CardBody,
  Text,
  Heading,
} from "../../common/components";
import { Article } from "../../types";

export default function ArticleContent({ article }: { article: Article }) {
  return (
    <Card as="article">
      <CardHeader>
        <Heading as="h1">{article.title}</Heading>
      </CardHeader>
      <CardBody>
        <Text as="p" fontSize="md">
          {article.content}
        </Text>
      </CardBody>
    </Card>
  );
}
app/articles/[slug]/Comments.tsx
import {
  Card,
  CardBody,
  StackDivider,
  VStack,
  Text,
  Box,
  Avatar,
  Flex,
} from "../../common/components";
import { Comment } from "../../types";

export default async function Comments({
  commentPromise,
}: {
  commentPromise: Promise<Comment[]>;
}) {
  const comments = await commentPromise;

  if (comments.length === 0) {
    return (
      <Text as="p" fontSize="md">
        No comments yet.
      </Text>
    );
  }
  return (
    <VStack
      divider={<StackDivider borderColor="gray.200" />}
      spacing={4}
      as="ul"
      align="stretch"
      px={4}
    >
      {comments.map((comment) => (
        <CommentItem key={comment.id} comment={comment} />
      ))}
    </VStack>
  );
}

function CommentItem({ comment }: { comment: Comment }) {
  return (
    <Flex as="li" listStyleType="none" align="center">
      <Avatar
        size="sm"
        name={comment.author.name}
        src={comment.author.avatarUrl}
        mr={4}
      />
      <Text fontSize="sm">{comment.body}</Text>
    </Flex>
  );
}
app/articles/[slug]/LoadingComments.tsx
import {
  StackDivider,
  VStack,
  Flex,
  SkeletonCircle,
  Skeleton,
} from "../../common/components";

export default function LoadingComments({}) {
  return (
    <VStack
      divider={<StackDivider borderColor="gray.200" />}
      spacing={4}
      as="ul"
      align="stretch"
      px={4}
    >
      <CommentSkeltonItem />
      <CommentSkeltonItem />
      <CommentSkeltonItem />
    </VStack>
  );
}

function CommentSkeltonItem() {
  return (
    <Flex as="li" listStyleType="none" align="center">
      <SkeletonCircle size="8" mr={4} />
      <Skeleton height="14px" width="60%" />
    </Flex>
  );
}
app/articles/[slug]/index.tsx
import ArticleContent from "./ArticleContent";
import Comments from "./Comments";
import { Heading } from "../../common/components";
import LoadingComments from "./LoadingComments";

const getArticle = async (slug: string) => {
  // ...
}

const getComments = async (slug: string) => {
  // ...
}

export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const articlePromise = getArticle(params.slug);
  const commentPromise = getComments(params.slug);

  const article = await articlePromise;

  return (
    <div>
      <ArticleContent article={article} />
      <Heading as="h2" mt={8} mb={4}>
        Comments
      </Heading>
      <Suspense fallback={<LoadingComments />}>
        {/* @ts-expect-error Currently, TypeScript reports a type error if JSX returns a Promise, but this will be resolved in the future */}
        <Comments commentPromise={commentPromise} />
      </Suspense>
    </div>
  );
}

Ultimately, the display will look like this:

Article details styled by Chakra UI

Creating an Article

Next, we will implement the article creation feature. Since we want to display a creation form at the path /articles/new, create a file named app/articles/new/page.tsx. We will implement it as a Client Component because it performs state management using a form.

app/articles/new/index.tsx
"use client";

import { useState } from "react";

import { useRouter } from "next/navigation";
import {
  Heading,
  FormControl,
  FormLabel,
  Input,
  Textarea,
  Button,
} from "../../common/components";

export default function CreateArticle() {
  const router = useRouter();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    await fetch("/api/articles", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title, content }),
    });
    setLoading(false);
    router.push("/");
  };

  return (
    <div>
      <Heading mb={4}>Create Article</Heading>

      <form onSubmit={handleSubmit}>
        <FormControl>
          <FormLabel>Title</FormLabel>
          <Input value={title} onChange={(e) => setTitle(e.target.value)} />

          <FormLabel>Body</FormLabel>
          <Textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
          />
          <Button
            type="submit"
            color="white"
            bg="orange.400"
            isLoading={loading || isPending}
            mt={4}
          >
            Create
          </Button>
        </FormControl>
      </form>
    </div>
  );
}

It displays a form to input the article title and content. When the create button is pressed, it calls the article creation API using fetch. Once the API call is complete, it navigates to the top page using router.push("/").

To use useRouter within the App Router, you need to import it from next/navigation instead of next/router.

As shown below, the form is displayed and you can now create articles.

Article creation form styled by Chakra UI

However, there is one problem. After creating an article and navigating to the top page, the newly created article is not displayed.

The posted article is not displayed when navigating to the article list screen after posting

This is because page transitions via router.push are "Soft Navigations". In Soft Navigation, if a cache for the destination exists, it is reused, and no new request is made to the server.

Since the cache for the top page exists, the old article list is being displayed. To invalidate the cache, you need to call router.refresh.

app/articles/new/index.tsx
- import { useState } from "react";
+ import { useState, useTransition } from "react";

  // ...

+   const [isPending, startTransition] = useTransition();

    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      setLoading(true);
      await fetch("/api/articles", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ title, content }),
      });
      setLoading(false);
      router.push("/");
+     startTransition(() => {
+       router.refresh();
+     });
    };

By calling router.refresh to invalidate the cache, new data is now fetched from the server when navigating to the top page.

The posted article is displayed when navigating to the article list screen after posting

Summary

We have reviewed the basic operations of the App Router. The fact that Server Components are the default is a major feature, and it feels like a welcome change that enables the creation of higher-performance applications.

Additionally, one of the key points of the App Router is the ability to group files related to a page within a single directory. This might suggest different approaches compared to traditional file configurations.

References

GitHubで編集を提案

Discussion

quantumentanglementquantumentanglement
npm run install

とすると

npm ERR! Missing script: "install"

というエラーがでました。

npm install

として実行しました。

1
azukiazusaazukiazusa

おっしゃるとおり、npm install が正しいコマンドでした🙇ご指摘ありがとうございます。

1
nokonokonokonoko

記事の詳細ページの箇所で詰まっています。

app/articles/[slug]/page.tsx
このファイルはどうするのですか?

azukiazusaazukiazusa

コメントありがとうございます。どのような事象が発生していて詰まっているのか、具体的に提示いただけると回答できそうです。

nokonokonokonoko

marlさんと同じ事象でしたので解決しました。
ありがとうございました。

marlmarl

初めまして。こちらの記事を見て勉強させていただいてます。
nextjs-app-dir-exampleをcloneさせていただいたのですが、詳細ページのコメントの表示がうまくいかず、エラーが返されてしまいます。
getComments関数の、http://localhost:3000/api/articles/${slug}/commentsを見てみると[]しか表示されず、コメントがうまく引っ張って来れていないことが原因かなと思っているのですがこちらよろしければご教授いただきたいです><

marlmarl

お返事ありがとうございます!2番目の記事はコメントもともと紐づいてなかったです...!1つ目の記事のURLだとデータはうまく表示されました。失礼いたしました。

コメント部分( <Comments commentPromise={commentPromise} />)のところを 表示するとブラウザにerror.tsxのメッセージが表示されます。
また、Comments.tsx:36 Uncaught (in promise) TypeError: comments.map is not a function とエラーが出ており、commentsの中身が取得できていなさそうです...

app/articles/[slug]/page.tsx を作成し、そこに詳細ページのコードを置いています。
この記事のapp/pages/articles/[slug].tsxに書く方法だと404が表示されてしまったためです。

お手数おかけしてしまい申し訳ありません。お手隙の際に見ていただけますとありがたいです。


1
azukiazusaazukiazusa

詳細な情報のご提示ありがとうございます🙇

記事の中では getComments の最後の行が return data.comments as Comment[] となっておりますが、正しくは return data as Comment[] でした。

app/articles/[slug].tsx ではなく、app/articles/[slug]/page.tsx とするのもおっしゃるとおりでした。ご報告誠にありがとうございます。

1
marlmarl

ありがとうございます!
commentsも、articleと同じようにapp/articles/[slug]/page.tsxのところで const comments = await commentPromise;として、 commentsをpropsで受け渡したらコメントが表示されるようにはできました!
ありがとうございました。

1