LoL Builds Browser with Next.js
Build a League of Legends builds browser using Next.js App Router.
Introduction
30 Second Summary
Every League of Legends player has searched for the perfect champion build. You open a website, filter by role, click a champion, and see exactly what items and runes to bring into your next game.
In this project, you will build that exact kind of website using Next.js. You will create multiple pages with navigation, dynamic routes for each champion build, and interactive role-based filtering.
What You'll Build
A multi-page Next.js application that uses the App Router for file-system routing, shared layouts, Server Components, Client Components, and dynamic routes.
By the end of this project, you'll have:
- A Next.js App Router project with TypeScript and Tailwind CSS scaffolded and running locally.
- A shared root layout with site-wide navigation that persists across page changes.
- Dynamic route pages that generate individual champion build detail pages from hardcoded data.
- A Client Component filter that demonstrates the Server/Client Component boundary with interactive role-based filtering.
- Secret Mission: Add search functionality using URL search params so users can search builds by champion name.
Are there any prerequisites?
You should have basic HTML, CSS, and JavaScript knowledge. Familiarity with React concepts like components, props, and state will help you follow along.
Set Up the Next.js Project
Every Next.js app starts with a scaffold. The scaffold gives you a project folder with routing, styling, and a development server ready to go.
In this step, you will use create-next-app to generate the project. Then you will replace the default starter page with your own content to confirm everything is connected.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript, Tailwind CSS, and the App Router.
- Start the development server and confirm the starter page loads.
- Replace the starter page with a custom placeholder and verify hot reload.
Scaffold the Next.js project
The create-next-app CLI generates a complete project structure in one command. It handles folder creation, dependency installation, and default configuration so you can start coding immediately.
- In VS Code, open a new terminal by clicking **Terminal** in the top menu bar, then **New Terminal**.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest lol-builds
- The installer will ask: Would you like to use the recommended Next.js defaults? Select Yes, use recommended defaults.
What does this set up?
Selecting the recommended defaults configures your project with TypeScript, ESLint, Tailwind CSS, and the App Router. These are the industry-standard choices for new Next.js projects.
The installer creates the lol-builds folder, installs all dependencies, and generates a working starter page.
- Once the installer finishes, move into your project folder and start the development server by running these commands:
cd lol-builds
npm run dev
- Open http://localhost:3000 in your browser.
You should see the default Next.js starter page with the Next.js logo and links.
Don't see the starter page?
- Check your terminal for error messages. If it says the port is in use, stop whatever is running on port 3000 first, then run npm run dev again.
- Make sure you ran cd lol-builds before npm run dev. The command must run from inside the project folder.
- If you see command not found: npx, your Node.js installation may not be on your PATH. Close and reopen VS Code, then try again.
Still stuck? Help me troubleshoot why my Next.js dev server is not starting.
Clean the scaffold and verify hot reload
The starter page is full of demo content you do not need. You will replace it with a minimal placeholder to confirm that changes you save appear in the browser automatically.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the lol-builds folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, expand the app folder.
- Click page.tsx to open it.
- Select all the existing content in page.tsx by pressing Cmd+A (macOS) or Ctrl+A (Windows).
- Delete it, then paste the following code:
export default function Home() {
return <h1>LoL Builds</h1>;
}
What does this code do?
This defines a React component called Home that renders a single heading. Next.js automatically uses page.tsx as the UI for the / route, so this becomes your home page.
- Save page.tsx by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Switch back to your browser at http://localhost:3000. You should see just the text "LoL Builds" on the page.
Still seeing the old starter page?
- Make sure you saved the file. Look for the dot on the page.tsx tab in VS Code. A dot means unsaved changes.
- Confirm you edited app/page.tsx and not a different file. The app folder is at the top level of your project.
- If the page does not update, try a hard refresh in your browser with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
Still stuck? Help me figure out why my page.tsx changes are not showing in the browser.
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/page.tsx and your browser shows "LoL Builds" at http://localhost:3000.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx at this point:
export default function Home() {
return <h1>LoL Builds</h1>;
}
Your project is scaffolded and your dev server is running. Next up, you will create the champion build data and turn the home page into something worth browsing.
Create the Data Layer and Home Page
Your Next.js project is scaffolded and the dev server is running. Now it is time to build the actual content that users will see.
Every app needs data to display. Before we can show champion builds on screen, we need to define what a "build" looks like in code and store some example builds. Then we will create a proper home page and add navigation so users can move between pages.
In this step, get ready to:
- Create a data file with TypeScript types and 6 champion builds.
- Build a styled home page with a hero section and featured build cards.
- Add a navigation bar to the root layout so users can move between pages.
Create the build data file
In Next.js, any TypeScript file without a "use client" directive runs on the server by default. This makes a plain TypeScript module the perfect place to store your app's data.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder lib.
- Right-click the new lib folder and select New File.
- Name the file data.ts.
First, define the shape of your data by pasting the following type and interface at the top of app/lib/data.ts:
export type Role = "Top" | "Jungle" | "Mid" | "ADC" | "Support";
export interface Build {
slug: string;
champion: string;
title: string;
role: Role;
items: string[];
runes: {
keystone: string;
primaryTree: string;
secondaryTree: string;
};
skillOrder: string[];
description: string;
winRate: string;
}
What does this code do?
The Role type restricts champion roles to five valid strings. TypeScript will flag a typo like "Jungler" at compile time.
The Build interface describes every property a champion build object must have. This gives you autocomplete in VS Code and catches missing fields before you even run the app.
You won't see a change in the browser yet. The next code block adds the actual build data that uses these types.
- Below the interface, paste the builds array with 6 champion builds:
export const builds: Build[] = [
{
slug: "jinx-adc-crit",
champion: "Jinx",
title: "Crit Hypercarry",
role: "ADC",
items: [
"Infinity Edge",
"Rapid Firecannon",
"Phantom Dancer",
"Bloodthirster",
"Lord Dominik's Regards",
"Berserker's Greaves",
],
runes: {
keystone: "Lethal Tempo",
primaryTree: "Precision",
secondaryTree: "Sorcery",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"Classic Jinx ADC build focused on critical strike scaling. Excels in teamfights with Fishbones rocket splash damage. Farm safely early, then dominate late game with massive range and attack speed.",
winRate: "52.3%",
},
{
slug: "darius-top-bruiser",
champion: "Darius",
title: "Bruiser Juggernaut",
role: "Top",
items: [
"Trinity Force",
"Sterak's Gage",
"Dead Man's Plate",
"Spirit Visage",
"Death's Dance",
"Plated Steelcaps",
],
runes: {
keystone: "Conqueror",
primaryTree: "Precision",
secondaryTree: "Resolve",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"Darius thrives in extended trades with his passive bleed stacks. Build tanky with Trinity Force as your damage spike. Stack Noxian Might in teamfights and dunk resets to pentakill.",
winRate: "51.8%",
},
{
slug: "lee-sin-jungle-assassin",
champion: "Lee Sin",
title: "Early Game Assassin",
role: "Jungle",
items: [
"Youmuu's Ghostblade",
"Black Cleaver",
"Edge of Night",
"Guardian Angel",
"Death's Dance",
"Ionian Boots of Lucidity",
],
runes: {
keystone: "Electrocute",
primaryTree: "Domination",
secondaryTree: "Inspiration",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"High-tempo Lee Sin build for early game dominance. Gank aggressively pre-6 and snowball lanes. Use the Insec kick combo to pick off carries in teamfights. Falls off late so close games early.",
winRate: "49.7%",
},
{
slug: "ahri-mid-burst",
champion: "Ahri",
title: "Burst Mage Assassin",
role: "Mid",
items: [
"Luden's Companion",
"Shadowflame",
"Rabadon's Deathcap",
"Zhonya's Hourglass",
"Void Staff",
"Sorcerer's Shoes",
],
runes: {
keystone: "Electrocute",
primaryTree: "Domination",
secondaryTree: "Inspiration",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"Ahri burst combo build. Land Charm into full rotation for one-shot potential on squishy targets. Spirit Rush provides safety and chase. Strong roaming mid-laner with pick potential.",
winRate: "52.1%",
},
{
slug: "thresh-support-utility",
champion: "Thresh",
title: "Playmaker Utility",
role: "Support",
items: [
"Locket of the Iron Solari",
"Knight's Vow",
"Zeke's Convergence",
"Redemption",
"Vigilant Wardstone",
"Boots of Swiftness",
],
runes: {
keystone: "Aftershock",
primaryTree: "Resolve",
secondaryTree: "Inspiration",
},
skillOrder: ["Q", "E", "W", "Q", "Q", "R"],
description:
"Thresh excels at creating picks with Death Sentence and peeling carries with Flay. Lantern saves turn fights. Build tanky utility to survive engages and protect your ADC throughout the game.",
winRate: "50.9%",
},
{
slug: "garen-top-tank",
champion: "Garen",
title: "Tanky Spin-to-Win",
role: "Top",
items: [
"Stridebreaker",
"Dead Man's Plate",
"Force of Nature",
"Randuin's Omen",
"Sterak's Gage",
"Berserker's Greaves",
],
runes: {
keystone: "Conqueror",
primaryTree: "Precision",
secondaryTree: "Resolve",
},
skillOrder: ["Q", "E", "W", "E", "E", "R"],
description:
"Simple and effective Garen build. Silence with Q, spin with E, execute with R. Passive regeneration makes you incredibly hard to push out of lane. Great for learning top lane fundamentals.",
winRate: "53.1%",
},
];
What does this data represent?
Each object in the builds array represents one champion build guide. The slug property is a URL-friendly identifier that will power our dynamic routes later (e.g. /builds/jinx-adc-crit).
The 6 builds cover all 5 roles (Top, Jungle, Mid, ADC, Support). This is hardcoded data that could easily be swapped for an API call in a future project.
- Save app/lib/data.ts.
- You won't see a change in the browser yet. The home page will import and display this data in the next substep.
Seeing red squiggles in VS Code?
Make sure you pasted both the type/interface block AND the builds array into the same file. The array uses the Build and Role types defined above it.
Check that the file is saved at exactly app/lib/data.ts (not app/data.ts or lib/data.ts).
Help me fix TypeScript errors in my data.ts file
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/lib/data.ts before moving on.
ⓧ I'd like to double check the full code
export type Role = "Top" | "Jungle" | "Mid" | "ADC" | "Support";
export interface Build {
slug: string;
champion: string;
title: string;
role: Role;
items: string[];
runes: {
keystone: string;
primaryTree: string;
secondaryTree: string;
};
skillOrder: string[];
description: string;
winRate: string;
}
export const builds: Build[] = [
{
slug: "jinx-adc-crit",
champion: "Jinx",
title: "Crit Hypercarry",
role: "ADC",
items: [
"Infinity Edge",
"Rapid Firecannon",
"Phantom Dancer",
"Bloodthirster",
"Lord Dominik's Regards",
"Berserker's Greaves",
],
runes: {
keystone: "Lethal Tempo",
primaryTree: "Precision",
secondaryTree: "Sorcery",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"Classic Jinx ADC build focused on critical strike scaling. Excels in teamfights with Fishbones rocket splash damage. Farm safely early, then dominate late game with massive range and attack speed.",
winRate: "52.3%",
},
{
slug: "darius-top-bruiser",
champion: "Darius",
title: "Bruiser Juggernaut",
role: "Top",
items: [
"Trinity Force",
"Sterak's Gage",
"Dead Man's Plate",
"Spirit Visage",
"Death's Dance",
"Plated Steelcaps",
],
runes: {
keystone: "Conqueror",
primaryTree: "Precision",
secondaryTree: "Resolve",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"Darius thrives in extended trades with his passive bleed stacks. Build tanky with Trinity Force as your damage spike. Stack Noxian Might in teamfights and dunk resets to pentakill.",
winRate: "51.8%",
},
{
slug: "lee-sin-jungle-assassin",
champion: "Lee Sin",
title: "Early Game Assassin",
role: "Jungle",
items: [
"Youmuu's Ghostblade",
"Black Cleaver",
"Edge of Night",
"Guardian Angel",
"Death's Dance",
"Ionian Boots of Lucidity",
],
runes: {
keystone: "Electrocute",
primaryTree: "Domination",
secondaryTree: "Inspiration",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"High-tempo Lee Sin build for early game dominance. Gank aggressively pre-6 and snowball lanes. Use the Insec kick combo to pick off carries in teamfights. Falls off late so close games early.",
winRate: "49.7%",
},
{
slug: "ahri-mid-burst",
champion: "Ahri",
title: "Burst Mage Assassin",
role: "Mid",
items: [
"Luden's Companion",
"Shadowflame",
"Rabadon's Deathcap",
"Zhonya's Hourglass",
"Void Staff",
"Sorcerer's Shoes",
],
runes: {
keystone: "Electrocute",
primaryTree: "Domination",
secondaryTree: "Inspiration",
},
skillOrder: ["Q", "W", "E", "Q", "Q", "R"],
description:
"Ahri burst combo build. Land Charm into full rotation for one-shot potential on squishy targets. Spirit Rush provides safety and chase. Strong roaming mid-laner with pick potential.",
winRate: "52.1%",
},
{
slug: "thresh-support-utility",
champion: "Thresh",
title: "Playmaker Utility",
role: "Support",
items: [
"Locket of the Iron Solari",
"Knight's Vow",
"Zeke's Convergence",
"Redemption",
"Vigilant Wardstone",
"Boots of Swiftness",
],
runes: {
keystone: "Aftershock",
primaryTree: "Resolve",
secondaryTree: "Inspiration",
},
skillOrder: ["Q", "E", "W", "Q", "Q", "R"],
description:
"Thresh excels at creating picks with Death Sentence and peeling carries with Flay. Lantern saves turn fights. Build tanky utility to survive engages and protect your ADC throughout the game.",
winRate: "50.9%",
},
{
slug: "garen-top-tank",
champion: "Garen",
title: "Tanky Spin-to-Win",
role: "Top",
items: [
"Stridebreaker",
"Dead Man's Plate",
"Force of Nature",
"Randuin's Omen",
"Sterak's Gage",
"Berserker's Greaves",
],
runes: {
keystone: "Conqueror",
primaryTree: "Precision",
secondaryTree: "Resolve",
},
skillOrder: ["Q", "E", "W", "E", "E", "R"],
description:
"Simple and effective Garen build. Silence with Q, spin with E, execute with R. Passive regeneration makes you incredibly hard to push out of lane. Great for learning top lane fundamentals.",
winRate: "53.1%",
},
];
Build the home page
The home page is the first thing users see. It needs a hero section to introduce the app and featured build cards to draw users into exploring.
- Open app/page.tsx in the Explorer sidebar.
- Select all the existing content and replace it with the following code:
import Link from "next/link";
import { builds } from "./lib/data";
export default function Home() {
// Grab the first 3 builds to feature on the landing page
const featured = builds.slice(0, 3);
return (
<div>
{/* Hero section with app title and call-to-action */}
<section className="text-center py-16">
<h1 className="text-5xl font-bold mb-4">
Find Your <span className="text-indigo-400">Build</span>
</h1>
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
Browse champion builds for League of Legends. Find the right items,
runes, and skill order for your favorite champions.
</p>
<Link
href="/builds"
className="inline-block mt-8 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-lg font-medium transition-colors"
>
Browse All Builds
</Link>
</section>
{/* Featured builds grid showing 3 champion cards */}
<section>
<h2 className="text-2xl font-bold mb-6">Featured Builds</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{featured.map((build) => (
<Link
key={build.slug}
href={`/builds/${build.slug}`}
className="block p-6 bg-gray-800 rounded-lg border border-gray-700 hover:border-indigo-500 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">{build.champion}</h3>
<span className="text-xs px-2 py-1 bg-gray-700 rounded">
{build.role}
</span>
</div>
<p className="text-gray-400 text-sm">{build.title}</p>
<p className="text-green-400 text-sm mt-2">
{build.winRate} win rate
</p>
</Link>
))}
</div>
</section>
</div>
);
}
What does this code do?
The Link component from next/link gives you client-side navigation. When users click a link, Next.js swaps only the page content without a full browser reload.
The builds.slice(0, 3) grabs the first three builds (Jinx, Darius, Lee Sin) to feature on the home page. Each card links to its detail page using a template literal like /builds/jinx-adc-crit.
- Save app/page.tsx.
- Switch to your browser at http://localhost:3000. You should see a large "Find Your Build" heading, a purple "Browse All Builds" button, and three champion build cards below (Jinx, Darius, Lee Sin).
Page not updating?
Make sure you saved both app/lib/data.ts and app/page.tsx. The page imports data from data.ts, so both files must be saved.
If you see an error like "Module not found", double-check the import path is ./lib/data (relative to the app folder where page.tsx lives).
Help me debug why my Next.js home page is not showing the builds data
Why do the build card links 404?
Clicking a build card navigates to a URL like /builds/jinx-adc-crit, but that page does not exist yet. You will create the dynamic route for individual build pages in a later step. The 404 is expected for now.
✔️ Awesome, I've got everything!
Great. Make sure app/page.tsx is saved before moving on.
ⓧ I'd like to double check the full code
import Link from "next/link";
import { builds } from "./lib/data";
export default function Home() {
const featured = builds.slice(0, 3);
return (
<div>
<section className="text-center py-16">
<h1 className="text-5xl font-bold mb-4">
Find Your <span className="text-indigo-400">Build</span>
</h1>
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
Browse champion builds for League of Legends. Find the right items,
runes, and skill order for your favorite champions.
</p>
<Link
href="/builds"
className="inline-block mt-8 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-lg font-medium transition-colors"
>
Browse All Builds
</Link>
</section>
<section>
<h2 className="text-2xl font-bold mb-6">Featured Builds</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{featured.map((build) => (
<Link
key={build.slug}
href={`/builds/${build.slug}`}
className="block p-6 bg-gray-800 rounded-lg border border-gray-700 hover:border-indigo-500 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">{build.champion}</h3>
<span className="text-xs px-2 py-1 bg-gray-700 rounded">
{build.role}
</span>
</div>
<p className="text-gray-400 text-sm">{build.title}</p>
<p className="text-green-400 text-sm mt-2">
{build.winRate} win rate
</p>
</Link>
))}
</div>
</section>
</div>
);
}
Customize the root layout with navigation
A layout in the App Router wraps every page in your app. It preserves state and does not re-render when you navigate between pages. This makes it the perfect place for site-wide elements like a navigation bar.
- Open app/layout.tsx in the Explorer sidebar.
- Select all the existing content and replace it with the following code:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import "./globals.css";
// Load the Geist fonts that ship with the Next.js scaffold
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
// Metadata appears in the browser tab and search results
export const metadata: Metadata = {
title: "LoL Builds",
description: "Browse League of Legends champion builds",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-900 text-gray-100 min-h-screen`}
>
{/* Navigation bar visible on every page */}
<nav className="border-b border-gray-700 bg-gray-800">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center gap-6">
<Link href="/" className="text-xl font-bold text-indigo-400">
LoL Builds
</Link>
<Link
href="/builds"
className="text-gray-300 hover:text-white transition-colors"
>
Builds
</Link>
</div>
</nav>
<main className="max-w-6xl mx-auto px-4 py-8">{children}</main>
</body>
</html>
);
}
What does this code do?
The <nav> element creates a dark header bar with two Link components: one pointing to / (Home) and one to /builds (the builds listing page you will create in the next step).
The bg-gray-900 text-gray-100 classes on the <body> set a dark theme for the entire app. The <main> wrapper constrains page content to a max width and adds padding.
Why does the nav bar live in the layout?
Layouts preserve state and do not re-render on navigation. This means the nav bar stays in place while only the page content swaps. Users get instant navigation without the flash of a full page reload.
- Save app/layout.tsx.
- Refresh your browser at http://localhost:3000. You should see a dark navigation bar at the top with "LoL Builds" in purple and a "Builds" link next to it.
- Click the Builds link in the nav bar. You should see a 404 page. This is expected because the /builds route does not exist yet.
Don't see the nav bar?
Check that you replaced the entire content of app/layout.tsx, including the imports at the top. The file must have import Link from "next/link" for the navigation links to work.
If the page is blank, check your terminal for errors. A missing closing tag or syntax error in JSX will prevent the page from rendering.
Help me fix my layout.tsx so the navigation bar appears
✔️ Awesome, I've got everything!
Great. Double check you have saved app/layout.tsx before moving on.
ⓧ I'd like to double check the full code
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "LoL Builds",
description: "Browse League of Legends champion builds",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-900 text-gray-100 min-h-screen`}
>
<nav className="border-b border-gray-700 bg-gray-800">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center gap-6">
<Link href="/" className="text-xl font-bold text-indigo-400">
LoL Builds
</Link>
<Link
href="/builds"
className="text-gray-300 hover:text-white transition-colors"
>
Builds
</Link>
</div>
</nav>
<main className="max-w-6xl mx-auto px-4 py-8">{children}</main>
</body>
</html>
);
}
Your data layer is in place, the home page looks sharp, and navigation is wired up. Next up, you will create the builds listing page with a client-side filter so users can browse builds by role.
Build the Builds Listing Page with Filtering
Your home page is live with featured builds and a nav bar. Clicking Builds in the navigation gives you a 404, because that page does not exist yet.
In this step, you will create the builds listing page and add interactive role-based filtering. This is where you will see the difference between Server Components and Client Components in action.
In this step, get ready to:
- Create the builds listing page as a Server Component.
- Build an interactive filter component using Client Components.
- Verify instant navigation between pages.
Create the builds page
In the App Router, creating a folder inside app/ defines a new route. A page.tsx file inside that folder renders the UI for that route.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder builds.
- Right-click the new builds folder and select New File.
- Name the file page.tsx.
- Paste the following code into app/builds/page.tsx:
import { builds } from "../lib/data";
import BuildFilter from "../components/BuildFilter";
export default function BuildsPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-6">All Builds</h1>
<BuildFilter builds={builds} />
</div>
);
}
What does this code do?
This file is a Server Component by default because it does not have a "use client" directive. It runs on the server, imports the builds data, and passes it as a prop to a BuildFilter component.
The BuildFilter component does not exist yet. You will create it next.
What is a Server Component?
Server Components run on the server and send only their rendered HTML to the browser. They can import data directly, access databases, and read files without shipping that code to the user.
The trade-off is that Server Components cannot use browser features like useState or event handlers like onClick. That is where Client Components come in.
- Save app/builds/page.tsx.
Your browser will show an error at http://localhost:3000/builds because the BuildFilter component does not exist yet. That is expected.
Create the BuildFilter Client Component
The filter needs to respond to button clicks and update the displayed builds without a page reload. That requires browser interactivity, which means this component must be a Client Component.
- In the Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder components.
- Right-click the new components folder and select New File.
- Name the file BuildFilter.tsx.
- Paste the following code into app/components/BuildFilter.tsx:
"use client";
import { useState } from "react";
import Link from "next/link";
import type { Build, Role } from "../lib/data";
const roles: (Role | "All")[] = ["All", "Top", "Jungle", "Mid", "ADC", "Support"];
interface BuildFilterProps {
builds: Build[];
}
export default function BuildFilter({ builds }: BuildFilterProps) {
// Track the currently selected role filter
const [activeRole, setActiveRole] = useState<Role | "All">("All");
// Filter builds based on selected role
const filteredBuilds =
activeRole === "All"
? builds
: builds.filter((build) => build.role === activeRole);
return (
<div>
{/* Role filter buttons */}
<div className="flex flex-wrap gap-2 mb-6">
{roles.map((role) => (
<button
key={role}
onClick={() => setActiveRole(role)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeRole === role
? "bg-indigo-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{role}
</button>
))}
</div>
{/* Build cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBuilds.map((build) => (
<Link
key={build.slug}
href={`/builds/${build.slug}`}
className="block p-5 bg-gray-800 rounded-lg border border-gray-700 hover:border-indigo-500 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold">{build.champion}</h2>
<span className="text-xs px-2 py-1 bg-gray-700 rounded">
{build.role}
</span>
</div>
<p className="text-gray-400 text-sm mb-2">{build.title}</p>
<div className="flex items-center justify-between">
<p className="text-green-400 text-sm">{build.winRate} WR</p>
<p className="text-gray-500 text-xs">{build.runes.keystone}</p>
</div>
</Link>
))}
</div>
{/* Empty state message */}
{filteredBuilds.length === 0 && (
<p className="text-gray-500 text-center py-8">
No builds found for this role.
</p>
)}
</div>
);
}
What does this code do?
- "use client" at the top tells Next.js this component needs browser interactivity. It runs in the browser so it can use useState and handle click events.
- useState tracks which role button is active. When the user clicks a role, the component re-renders with only the matching builds.
- Each build card is wrapped in a Link component pointing to /builds/${build.slug}. These pages do not exist yet, so clicking a card will show a 404 for now.
Why separate Server and Client Components?
The builds page (page.tsx) stays on the server because it only needs to read data. The filter component (BuildFilter.tsx) runs in the browser because it needs useState for interactivity.
This boundary keeps most of your app lightweight. Only the parts that need interactivity get shipped to the browser as JavaScript.
- Save app/components/BuildFilter.tsx.
- Open http://localhost:3000/builds in your browser. You should see all 6 builds displayed as cards in a grid.
- Click the Top button. The list should filter down to just the 2 Top lane builds (Darius and Garen).
- Click All to see all 6 builds again.
Not seeing the builds page?
- Check that your file is at app/builds/page.tsx (not app/build/page.tsx or inside a different folder). The folder name must be builds to match the URL.
- Make sure BuildFilter.tsx is inside app/components/. The import path in page.tsx uses ../components/BuildFilter which expects that exact location.
- Confirm your dev server is still running in the terminal. If it stopped, run npm run dev again.
Help me debug why my builds page is not loading.
Verify navigation flow
Now that both pages exist, the navigation bar from your layout should work end-to-end.
- Click LoL Builds in the navigation bar to go to the home page.
- Click Builds in the navigation bar to go to the listing page.
- Click back and forth a few times. Notice the navigation is instant with no full page reload.
Why is navigation so fast?
The Link component from next/link prefetches linked pages in the background. When you click, the page content is already loaded. The layout (your nav bar) does not re-render at all.
✔️ Awesome, I've got everything!
Great. Make sure both app/builds/page.tsx and app/components/BuildFilter.tsx are saved.
ⓧ I'd like to double check the full code
Here is the full code for both files. Compare yours against these:
import { builds } from "../lib/data";
import BuildFilter from "../components/BuildFilter";
export default function BuildsPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-6">All Builds</h1>
<BuildFilter builds={builds} />
</div>
);
}
"use client";
import { useState } from "react";
import Link from "next/link";
import type { Build, Role } from "../lib/data";
const roles: (Role | "All")[] = ["All", "Top", "Jungle", "Mid", "ADC", "Support"];
interface BuildFilterProps {
builds: Build[];
}
export default function BuildFilter({ builds }: BuildFilterProps) {
const [activeRole, setActiveRole] = useState<Role | "All">("All");
const filteredBuilds =
activeRole === "All"
? builds
: builds.filter((build) => build.role === activeRole);
return (
<div>
<div className="flex flex-wrap gap-2 mb-6">
{roles.map((role) => (
<button
key={role}
onClick={() => setActiveRole(role)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeRole === role
? "bg-indigo-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{role}
</button>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBuilds.map((build) => (
<Link
key={build.slug}
href={`/builds/${build.slug}`}
className="block p-5 bg-gray-800 rounded-lg border border-gray-700 hover:border-indigo-500 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold">{build.champion}</h2>
<span className="text-xs px-2 py-1 bg-gray-700 rounded">
{build.role}
</span>
</div>
<p className="text-gray-400 text-sm mb-2">{build.title}</p>
<div className="flex items-center justify-between">
<p className="text-green-400 text-sm">{build.winRate} WR</p>
<p className="text-gray-500 text-xs">{build.runes.keystone}</p>
</div>
</Link>
))}
</div>
{filteredBuilds.length === 0 && (
<p className="text-gray-500 text-center py-8">
No builds found for this role.
</p>
)}
</div>
);
}
Your builds browser now has a fully interactive listing page with role filtering. Next up, you will create individual build detail pages using dynamic routes so each champion build gets its own dedicated URL.
Create Dynamic Build Detail Pages
Your builds listing page works. Clicking a build card navigates to a URL like /builds/jinx-adc-crit, but you see a 404 because that page does not exist yet.
In this step, you will create a single page template that handles every build URL. Next.js uses dynamic route segments to match variable URL paths to one page file, so you do not need to create a separate file for each champion.
In this step, get ready to:
- Create a dynamic route page that displays full build details for any champion.
- Add static generation so all build pages are pre-rendered at build time.
- Test the complete navigation flow from home to listing to detail pages.
Create the dynamic route
In Next.js App Router, wrapping a folder name in square brackets creates a dynamic segment. A folder called [slug] will match any URL path at that position and pass the value as a parameter to your page component.
- In the VS Code Explorer sidebar, right-click the app/builds folder.
- Click New Folder and name it [slug] (including the square brackets).
- Right-click the new [slug] folder and click New File.
- Name the file page.tsx.
- Paste the following code into app/builds/[slug]/page.tsx:
import Link from "next/link";
import { notFound } from "next/navigation";
import { builds } from "../../lib/data";
export default async function BuildPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
// Await the params Promise to extract the slug from the URL
const { slug } = await params;
// Find the matching build in our data
const build = builds.find((b) => b.slug === slug);
// Show the 404 page if no build matches the URL slug
if (!build) {
notFound();
}
return (
<div>
<Link
href="/builds"
className="text-indigo-400 hover:text-indigo-300 mb-6 inline-block"
>
← Back to Builds
</Link>
<div className="bg-gray-800 rounded-lg border border-gray-700 p-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">{build.champion}</h1>
<span className="px-3 py-1 bg-indigo-600 rounded-lg text-sm font-medium">
{build.role}
</span>
</div>
<p className="text-gray-400 text-lg mb-6">{build.title}</p>
<p className="text-green-400 mb-8">{build.winRate} win rate</p>
<p className="text-gray-300 mb-8">{build.description}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h2 className="text-xl font-semibold mb-3">Items</h2>
<ol className="space-y-2">
{build.items.map((item, index) => (
<li key={index} className="text-gray-300">
<span className="text-gray-500 mr-2">{index + 1}.</span>
{item}
</li>
))}
</ol>
</div>
<div>
<h2 className="text-xl font-semibold mb-3">Runes</h2>
<div className="space-y-2">
<p className="text-gray-300">
<span className="text-gray-500">Keystone:</span>{" "}
{build.runes.keystone}
</p>
<p className="text-gray-300">
<span className="text-gray-500">Primary:</span>{" "}
{build.runes.primaryTree}
</p>
<p className="text-gray-300">
<span className="text-gray-500">Secondary:</span>{" "}
{build.runes.secondaryTree}
</p>
</div>
</div>
<div>
<h2 className="text-xl font-semibold mb-3">Skill Order</h2>
<div className="flex gap-2">
{build.skillOrder.map((skill, index) => (
<span
key={index}
className="w-8 h-8 flex items-center justify-center bg-gray-700 rounded text-sm font-medium"
>
{skill}
</span>
))}
</div>
</div>
</div>
</div>
</div>
);
}
What does this code do?
This is an async Server Component. In Next.js 16, the params prop is a Promise that you must await before reading the slug value.
- await params extracts the slug from the URL. If the URL is /builds/jinx-adc-crit, the slug is jinx-adc-crit.
- builds.find() searches the data array for a build whose slug matches the URL.
- notFound() from next/navigation triggers the Next.js 404 page when someone visits a non-existent build URL.
- The return renders a full detail card with the champion name, role badge, items list, runes, and skill order.
- Save app/builds/[slug]/page.tsx.
- In your browser, navigate to http://localhost:3000/builds/jinx-adc-crit.
- You should see a detail page with Jinx's champion name, the ADC role badge, a list of 6 items, runes, and skill order boxes.
Seeing a 404 or blank page?
- Confirm the folder is named exactly [slug] with the square brackets. If you named it slug without brackets, Next.js treats it as a static route.
- Make sure the file inside is named page.tsx (not Page.tsx or index.tsx).
- Check the import path: ../../lib/data goes up two levels from [slug] to reach the app folder.
Why is my dynamic route page not rendering?
Why is params a Promise?
In earlier versions of Next.js, params was a plain object. Starting in Next.js 16, params is fully async and must be awaited. This allows the framework to stream page content more efficiently.
Add generateStaticParams for static generation
Your detail pages work in development, but Next.js can also pre-render them at build time. This means every build page loads instantly in production because the HTML already exists.
- In app/builds/[slug]/page.tsx, find the line that says:
import { builds } from "../../lib/data";
- Add the following generateStaticParams function directly below the imports and above the export default async function BuildPage line:
export function generateStaticParams() {
return builds.map((build) => ({
slug: build.slug,
}));
}
What does generateStaticParams do?
This function tells Next.js which slug values exist at build time. Next.js calls it once, gets back all 6 slugs from your data, and pre-renders a static HTML page for each one.
In production, visitors load a pre-built HTML file instead of waiting for the server to render on each request. This is how production Next.js apps serve known pages instantly.
- Save app/builds/[slug]/page.tsx.
- You will not see a visible change in development mode. This optimization takes effect when you run npm run build for production.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/builds/[slug]/page.tsx file:
import Link from "next/link";
import { notFound } from "next/navigation";
import { builds } from "../../lib/data";
export function generateStaticParams() {
return builds.map((build) => ({
slug: build.slug,
}));
}
export default async function BuildPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const build = builds.find((b) => b.slug === slug);
if (!build) {
notFound();
}
return (
<div>
<Link
href="/builds"
className="text-indigo-400 hover:text-indigo-300 mb-6 inline-block"
>
← Back to Builds
</Link>
<div className="bg-gray-800 rounded-lg border border-gray-700 p-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">{build.champion}</h1>
<span className="px-3 py-1 bg-indigo-600 rounded-lg text-sm font-medium">
{build.role}
</span>
</div>
<p className="text-gray-400 text-lg mb-6">{build.title}</p>
<p className="text-green-400 mb-8">{build.winRate} win rate</p>
<p className="text-gray-300 mb-8">{build.description}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h2 className="text-xl font-semibold mb-3">Items</h2>
<ol className="space-y-2">
{build.items.map((item, index) => (
<li key={index} className="text-gray-300">
<span className="text-gray-500 mr-2">{index + 1}.</span>
{item}
</li>
))}
</ol>
</div>
<div>
<h2 className="text-xl font-semibold mb-3">Runes</h2>
<div className="space-y-2">
<p className="text-gray-300">
<span className="text-gray-500">Keystone:</span>{" "}
{build.runes.keystone}
</p>
<p className="text-gray-300">
<span className="text-gray-500">Primary:</span>{" "}
{build.runes.primaryTree}
</p>
<p className="text-gray-300">
<span className="text-gray-500">Secondary:</span>{" "}
{build.runes.secondaryTree}
</p>
</div>
</div>
<div>
<h2 className="text-xl font-semibold mb-3">Skill Order</h2>
<div className="flex gap-2">
{build.skillOrder.map((skill, index) => (
<span
key={index}
className="w-8 h-8 flex items-center justify-center bg-gray-700 rounded text-sm font-medium"
>
{skill}
</span>
))}
</div>
</div>
</div>
</div>
</div>
);
}
Test the complete navigation flow
Every page in your app is now connected. Time to confirm the full loop works end to end.
- Navigate to http://localhost:3000 in your browser.
- Click one of the featured build cards on the home page.
- Confirm the detail page loads with the champion name, role, items, runes, and skill order.
- Click the Back to Builds link at the top of the detail page.
- Confirm you are back on the builds listing page.
- Click any build card from the listing page and confirm it opens the correct detail page.
- Try a non-existent URL by typing http://localhost:3000/builds/fake-build in the address bar.
- Confirm you see the Next.js 404 page.
Why is navigation so fast?
Next.js prefetches linked pages in the background when Link components appear in the viewport. By the time you click, the page data is already loaded. This is why navigation feels instant compared to a full page reload.
Build cards still showing 404?
- Make sure the slug values in your data.ts file match the href pattern in your Link components. The URL /builds/jinx-adc-crit must match the slug: "jinx-adc-crit" field exactly.
- Confirm your folder structure is app/builds/[slug]/page.tsx. The brackets in the folder name are required.
Help me debug why my dynamic route is not matching build URLs.
Your app now has a complete navigation loop from home page to listing to detail and back. Next up, you will tackle the Secret Mission and add search functionality with URL parameters.
Secret mission
Add Search with URL Search Params
Your builds page has role filtering, but what if a player just wants to search for their main champion by name? In this mission, you will add a search input that syncs its value to the URL. This means the search is shareable and survives a page refresh.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The lol-builds project folder on your Desktop (contains all source code and dependencies).
- The Next.js development server running at http://localhost:3000.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your LoL Builds app.
- Your code stays in the lol-builds folder.
- If the dev server is still running, you can keep using http://localhost:3000 in your browser.
- To restart the server later, open a terminal in the lol-builds folder and run npm run dev.
Pause - I'll come back to this later
Shut down the running development server to free up your terminal. Your project files stay on disk, ready to pick up again.
- Switch to the terminal where npm run dev is running.
- Press Ctrl+C to stop the development server.
- When you are ready to resume, open a terminal in the lol-builds folder and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. Nothing else to clean up since this project runs entirely locally.
- Stop the development server by pressing Ctrl+C in its terminal.
- Delete the lol-builds folder by running this command from your Desktop:
cd ~/Desktop
rm -rf lol-builds
On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q lol-builds
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see lol-builds in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully navigable League of Legends champion builds browser using Next.js 16 and the App Router.
You've learned how to:
- Built a multi-page app using file-system routing with the App Router, where folders and page.tsx files define your routes.
- Created dynamic routes with [slug] segments and async params, plus used generateStaticParams for static page generation at build time.
- Built a Client Component with useState for interactive role-based filtering, while keeping parent pages as Server Components.
- Secret Mission: Implemented URL-based search with useSearchParams for shareable, persistent filters that survive page refreshes.
Ready to quiz yourself?