App Router Content Sites Need One Data Source Driving Routes, Metadata, and 404s
Learning App Router: What You Really Need to Connect Are Routes, Data, and Failure Branches
Many App Router examples only tell you: create a directory, then drop in a page.tsx. That's enough to display a page, but not enough to explain how a content site stays consistent over time. The more critical questions are: where do the articles in the list come from? How are dynamic details looked up? How does the build step know which slugs exist? How does the page title follow the article? And who ultimately handles unknown addresses?
Based on the static source code of a small Next.js 16.3.1 project, this article dissects a complete chain from a shared layout to a global 404. You'll get a transferable content site structure and a self-check checklist. The project code was not executed during this writing process; its runtime behavior is unverified.
Start with the Conclusion: Don't Learn the Five APIs Separately
This project includes a homepage, About, a Blog list, two dynamic detail pages, and a root-level 404. What truly connects them are two main threads:
UI Route Thread: URL → layout → page → not-found
Content Data Thread: blogPosts → card → slug → detail → metadata
layout.tsx, [slug], generateStaticParams, generateMetadata, and notFound() are not independent knowledge points. They collectively answer one question: how a piece of content is discovered, located, described, and handled as a fallback.
The Route Tree First Expresses Page Relationships
The project's core directory is very small:
app/
├─ layout.tsx
├─ not-found.tsx
├─ about/page.tsx
└─ blog/
├─ page.tsx
├─ posts.ts
└─ [slug]/page.tsx
The root layout loads the Geist font and provides global navigation; children is the currently matched page. This way, About, Blog, and article details don't need to maintain separate navigation.
The directory hierarchy also directly expresses URLs: about/page.tsx is /about, blog/page.tsx is /blog, and blog/[slug]/page.tsx receives any article slug. The directory is the route structure, and the layout is the shared UI structure; together they form the page tree.
A Single Data Source Is More Important Than Dynamic Routes Themselves
The project has no database; instead, it defines BlogPost and two articles in posts.ts. Each piece of data simultaneously contains:
slug: the route key;title,description: for cards and metadata;category,publishedAt: display information;topics,takeaways: detail content;sourceUrl: external original text.
The list page only does mapping:
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.description}</p>
</Link>
))}
Detail lookups also have a single entry point:
export function getBlogPost(slug: string) {
return blogPosts.find((post) => post.slug === slug);
}
The unique detail in this material is: this array not only serves the list and details but also simultaneously drives static parameters and dynamic metadata. Once data is centralized, adding a new article doesn't require maintaining multiple route tables.
In Next.js 16, Respect Local Types First
Dynamic page props are written as:
type BlogPostPageProps = {
params: Promise<{ slug: string }>;
};
The page first await params, then queries the article:
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) notFound();
return <article>{post.title}</article>;
}
This reminds us of a more general engineering judgment: Next.js's APIs and types change with versions. When old tutorials conflict with the current project, the priority should be "installed version docs → local types → example articles," not modifying code from memory.
Build Parameters and Page Metadata Must Come from the Same Source of Truth
Articles known at build time can be declared via generateStaticParams:
export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug }));
}
Dynamic titles are generated by generateMetadata:
export async function generateMetadata({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) return { title: "Article Not Found" };
return {
title: post.title,
description: post.description,
};
}
Neither maintains extra constants; both return to blogPosts. This ensures that "whether a page exists" and "how a page is described" never diverge from the content itself.
Failure Branches Should Return to Route Conventions
When an unknown slug is queried by getBlogPost and returns undefined, the page calls notFound(). The root-level app/not-found.tsx provides a unified 404 visual and two recovery entry points.
If the detail component simply return null, the user sees no reason, and the framework gets no clear "not found" semantics. Handing the failure branch to notFound() means letting a non-existent business resource re-enter the routing system.
It can be remembered as a fixed chain:
URL slug
→ await params
→ getBlogPost
→ has data: render detail
→ no data: notFound
→ root-level not-found.tsx
UI Components Must Also Obey Navigation Semantics
The project formally initializes shadcn/ui. Button is based on Base UI, CVA manages variants like default and outline, and Tailwind class names are merged via cn.
Noteworthy is the link button: it is not written as a button nested inside a link, but uses Base UI's render:
<Button
variant="outline"
render={<Link href="/blog" />}
>
Back to Blog
</Button>
External original links similarly use <a> as the final element and retain target="_blank" and rel="noopener noreferrer". Component reuse must not come at the expense of HTML semantics.
A Directly Reusable Content Site Self-Check Checklist
Route Structure
- Shared UI is in the nearest layout, not duplicated across multiple pages.
- Dynamic directory names match business keys, e.g.,
[slug]corresponds topost.slug. - hrefs in the list indeed point to dynamic routes.
Data Consistency
- List, detail, static parameters, and metadata use the same data source.
- Adding a new article only requires adding one data entry, not modifying multiple route configurations.
- Query functions return an explicit null value for unknown slugs.
Framework Boundaries
- Handle
paramsaccording to the current Next.js version. - Dynamic content uses
generateMetadata; static content directly exports metadata. - Unknown resources call
notFound(), and the root route providesnot-found.tsx.
Interaction Semantics
- Internal navigation uses Next.js
Link. - External links retain security attributes.
- Button-styled links ultimately still render as link elements.
What Can Still Be Improved
The current homepage still retains the starting content from create-next-app, and the root metadata is also the default title; if the project is to truly become a content site, the next step should be to unify the site name and description.
The current article data is a synchronous static array. When switching to a CMS or database, the getBlogPost(slug) boundary can be kept, only changing the internal implementation to asynchronous reading. This way, the responsibilities of the list, detail, and metadata won't be disrupted by changes in the data source.
Finally, tests should be added for two branches: a valid slug can render the article, and an unknown slug enters 404. The current project has no test configuration, so only the verification direction is given here, without claiming it has been run and passed.
Conclusion
The focus of learning App Router is not how many special files you memorize, but whether you can draw the complete data and route chain. For a content site, a transferable judgment is: let the same data source determine the page entry, page content, and page description, and then let framework conventions handle the failure branch.
Now check your project: if adding a new article requires simultaneously changing the list, detail, route parameters, and title configuration, it means a single source of truth has not yet been established.
Summary: Through a small Next.js 16.3.1 content site, this article explains how Root Layout, dynamic slug, generateStaticParams, generateMetadata, notFound, and shadcn/ui link buttons form a complete chain; based only on static source code analysis, runtime is unverified.
Tags: Next.js / React / App Router / TypeScript / shadcn-ui