Next.js App Router: Complete Guide for Building Modern Web Apps
Everything you need to know about Next.js 13+ App Router, with practical examples from building our app studio site.
Winkle Team
Indie Developers
Next.js App Router: Complete Guide for Building Modern Web Apps
Next.js App Router (introduced in Next.js 13) revolutionized how we build React applications. Here's everything we learned building Winkle Studios with it.
Why We Chose Next.js App Router
As an indie developer studio, we needed:
- SEO-friendly - Server-side rendering out of the box
- Fast - Excellent performance by default
- DX - Great developer experience
- Scalable - From MVP to production
- Cost-effective - Deploys easily on Vercel
App Router delivered all of this.
App Router vs Pages Router
Key Differences:
Pages Router (Old):
pages/
āāā index.js
āāā blog/
ā āāā [slug].js
āāā api/
āāā hello.js
App Router (New):
app/
āāā page.tsx
āāā layout.tsx
āāā blog/
ā āāā page.tsx
ā āāā [slug]/
ā āāā page.tsx
āāā api/
āāā hello/
āāā route.ts
Benefits:
ā
React Server Components - Smaller bundles
ā
Layouts - Shared UI that doesn't re-render
ā
Loading UI - Built-in loading states
ā
Error Handling - Granular error boundaries
ā
Streaming - Progressive page rendering
ā
Better SEO - Metadata API
Core Concepts
1. File-Based Routing
Every folder is a route:
app/
āāā page.tsx ā /
āāā about/
ā āāā page.tsx ā /about
āāā blog/
ā āāā page.tsx ā /blog
ā āāā [slug]/
ā āāā page.tsx ā /blog/:slug
āāā tools/
āāā [tool]/
āāā page.tsx ā /tools/:tool
2. Special Files
- page.tsx - Route UI
- layout.tsx - Shared layout
- loading.tsx - Loading UI
- error.tsx - Error handling
- not-found.tsx - 404 page
3. Metadata API
Static Metadata:
// app/page.tsx
export const metadata: Metadata = {
title: 'Winkle Studios',
description: 'Indie app studio building useful tools',
}
Dynamic Metadata:
// app/blog/[slug]/page.tsx
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.description,
openGraph: {
images: [post.image],
},
}
}
4. Server vs Client Components
Default: Server Components
- Fetch data on server
- Smaller bundle size
- Direct database access
- Better SEO
// app/blog/page.tsx (Server Component)
async function BlogPage() {
const posts = await getPosts() // runs on server
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}
Client Components (use "use client")
- Interactive UI
- Browser APIs
- React hooks
- Event listeners
// components/LikeButton.tsx
"use client"
import { useState } from 'react'
export function LikeButton() {
const [likes, setLikes] = useState(0)
return (
<button onClick={() => setLikes(likes + 1)}>
Likes: {likes}
</button>
)
}
Real-World Examples from Our Site
1. Blog Listing Page
// app/blog/page.tsx
import { getPosts } from '@/lib/blog'
import { BlogCard } from '@/components/BlogCard'
export const metadata = {
title: 'Blog - Winkle Studios',
description: 'Insights on indie hacking, app development, and growth',
}
export default async function BlogPage() {
const posts = await getPosts()
const featured = posts.filter(p => p.featured)
return (
<div className="container py-16">
<h1 className="text-5xl font-light mb-8">Blog</h1>
{/* Featured Posts */}
<section className="mb-16">
<h2 className="text-2xl mb-6">Featured</h2>
<div className="grid md:grid-cols-2 gap-8">
{featured.map(post => (
<BlogCard key={post.slug} post={post} featured />
))}
</div>
</section>
{/* All Posts */}
<section>
<h2 className="text-2xl mb-6">All Posts</h2>
<div className="grid md:grid-cols-3 gap-8">
{posts.map(post => (
<BlogCard key={post.slug} post={post} />
))}
</div>
</section>
</div>
)
}
2. Dynamic Blog Post Page
// app/blog/[slug]/page.tsx
import { getPost, getPosts } from '@/lib/blog'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(post => ({ slug: post.slug }))
}
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug)
if (!post) return {}
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [post.image],
type: 'article',
publishedTime: post.date,
},
}
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug)
if (!post) {
notFound()
}
return (
<article className="prose lg:prose-xl mx-auto py-16">
<header className="mb-8">
<h1>{post.title}</h1>
<div className="flex gap-4 text-sm text-gray-600">
<time>{post.date}</time>
<span>{post.readTime}</span>
</div>
</header>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
3. Root Layout with SEO
// app/layout.tsx
import { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
metadataBase: new URL('https://winklestudios.com'),
title: {
default: 'Winkle Studios - Free Tools & Apps',
template: '%s | Winkle Studios'
},
description: 'Indie app studio building 10+ apps and free tools',
keywords: ['free tools', 'web apps', 'indie developer'],
authors: [{ name: 'Winkle Team' }],
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://winklestudios.com',
siteName: 'Winkle Studios',
},
twitter: {
card: 'summary_large_image',
creator: '@winklestudios',
},
robots: {
index: true,
follow: true,
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Performance Optimizations
1. Image Optimization
import Image from 'next/image'
<Image
src="/blog/post-image.jpg"
alt="Blog post"
width={800}
height={600}
priority={isFeatured}
placeholder="blur"
blurDataURL="data:image/..."
/>
2. Fonts Optimization
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}
3. Route Groups
Organize without affecting URL:
app/
āāā (marketing)/
ā āāā layout.tsx
ā āāā page.tsx
ā āāā about/
ā āāā page.tsx
āāā (app)/
āāā layout.tsx
āāā dashboard/
āāā page.tsx
Common Pitfalls & Solutions
ā Problem: "use client" everywhere
Solution: Only use on interactive components
ā Problem: Fetching in client components
Solution: Fetch in server components, pass as props
ā Problem: Missing metadata
Solution: Use generateMetadata for all pages
ā Problem: Slow builds
Solution: Use generateStaticParams for dynamic routes
Deployment on Vercel
- Connect GitHub repo
- Vercel auto-detects Next.js
- Build and deploy automatically
- Get preview URLs for PRs
Build optimizations:
// next.config.js
module.exports = {
images: {
domains: ['your-cdn.com'],
},
experimental: {
optimizeCss: true,
},
}
Results
Our site built with App Router:
- Lighthouse Score: 98/100
- First Contentful Paint: 0.8s
- Time to Interactive: 1.2s
- SEO Score: 100/100
Conclusion
Next.js App Router is production-ready and excellent for:
- Content-heavy sites (blogs, docs)
- E-commerce platforms
- Marketing websites
- SaaS applications
The SEO benefits, performance, and DX make it our go-to framework for all web projects.
Have you tried App Router? What's your experience?
Explore our open-source projects built with Next.js.
Found this helpful? Share it!
About Winkle Team
We're a small indie development studio building apps, tools, and digital products. Our mission is to create useful software that helps people work better and faster.
Ready to start building?
Check out our free tools or get in touch for your next project