Full-stack Laravel & Vue.js developer building SaaS platforms that scale

I design and develop scalable APIs, interactive Vue UIs, and automated SaaS workflows with Laravel. My goal is to build software that performs, looks great, and solves real business problems.

Next.js App Router: Production Patterns and Performance Optimization

After migrating multiple production applications to Next.js App Routerβ€”from SaaS platforms serving 100K+ daily active users to e-commerce sites processing millions in revenueβ€”I've learned that the App Router isn't just a routing system. It's a complete paradigm shift that fundamentally changes how we build React applications.

In this guide, I'll share the patterns, optimizations, and hard-won lessons that helped me achieve 95+ Lighthouse scores, sub-second page loads, and a developer experience that makes shipping features feel effortless.

πŸ’‘ What You'll Learn:
  • How Server Components actually work (and when NOT to use them)
  • Server Actions that replace your entire API layer
  • Caching strategies that make your app lightning fast
  • Production-tested patterns from real applications
  • Performance optimizations that matter

Understanding the App Router Mental Model

The biggest mistake I see developers make is treating the App Router like the Pages Router with a different folder structure. It's not. Here's the fundamental shift:

Pages Router: Client-first. Everything is a client component unless you explicitly opt into SSR/SSG.

App Router: Server-first. Everything is a server component unless you explicitly opt into client rendering.

βœ… Mental Model: Think of your app as running on the server by default. Client components are islands of interactivity in an ocean of server components. This is the opposite of traditional React!

When to Use Server vs Client Components

Use Server Components when:

  • Fetching data from databases or APIs
  • Accessing backend resources (files, env variables)
  • Keeping sensitive information server-side (API keys, tokens)
  • Reducing JavaScript bundle size

Use Client Components when:

  • Using React hooks (useState, useEffect, etc.)
  • Handling browser events (onClick, onChange)
  • Using browser APIs (localStorage, geolocation)
  • Building interactive UI (dropdowns, modals, forms with validation)
⚠️ Common Mistake: Making everything "use client" because you're used to it. This kills your performance! Start with Server Components by default, add "use client" only when you need interactivity.

1. File-Based Routing: Beyond the Basics

The App Router uses a powerful file convention system. Here's what I actually use in production:

app/
β”œβ”€β”€ (marketing)/          # Route group (URL not affected)
β”‚   β”œβ”€β”€ page.tsx         # Homepage at /
β”‚   β”œβ”€β”€ about/
β”‚   β”‚   └── page.tsx     # About page at /about
β”‚   └── layout.tsx       # Shared layout for marketing pages
β”‚
β”œβ”€β”€ (dashboard)/         # Another route group
β”‚   β”œβ”€β”€ dashboard/
β”‚   β”‚   β”œβ”€β”€ page.tsx     # Dashboard at /dashboard
β”‚   β”‚   β”œβ”€β”€ loading.tsx  # Loading state
β”‚   β”‚   β”œβ”€β”€ error.tsx    # Error boundary
β”‚   β”‚   └── layout.tsx   # Dashboard layout
β”‚
β”œβ”€β”€ blog/
β”‚   β”œβ”€β”€ page.tsx         # Blog list at /blog
β”‚   └── [slug]/
β”‚       └── page.tsx     # Dynamic route at /blog/[slug]
β”‚
β”œβ”€β”€ layout.tsx           # Root layout (wraps everything)
└── not-found.tsx        # 404 page

File Convention Breakdown

  • page.tsx: Defines a route and makes it publicly accessible
  • layout.tsx: Shared UI that wraps child pages (doesn't re-render on navigation!)
  • loading.tsx: Instant loading state with Suspense
  • error.tsx: Error boundary for that route segment
  • route.ts: API endpoint (replaces /pages/api)
πŸ’‘ Pro Tip: Use route groups (folder) to organize routes without affecting the URL structure. I use (marketing), (dashboard), and (auth) to keep things clean while sharing layouts.

2. Server Actions: The Game Changer

Server Actions let you call server code directly from your components without building an API layer. This was a game-changer for my productivity.

Basic Server Action

Here's a simple example that handles form submission:

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')
  
  // Save to database
  await db.post.create({
    data: { title, content }
  })
  
  // Revalidate the posts page cache
  revalidatePath('/posts')
}

Using it in a component:

// app/new-post/page.tsx
import { createPost } from '@/app/actions'

export default function NewPost() {
  return (
    <form action={createPost}>
      <input type="text" name="title" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}
βœ… Why This Is Powerful: No API routes needed! The form works even without JavaScript (progressive enhancement). The submission is automatically optimized with React's concurrent features.

Type-Safe Server Actions with Zod

In production, I always validate inputs with Zod:

'use server'

import { z } from 'zod'

const createPostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10)
})

export async function createPost(formData: FormData) {
  const result = createPostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content')
  })
  
  if (!result.success) {
    return { error: 'Validation failed' }
  }
  
  const post = await db.post.create({ data: result.data })
  revalidatePath('/posts')
  return { success: true, post }
}
⚠️ Security Note: Server Actions run on the server, but they're called from the client. Always validate inputs! Never trust data coming from formData.

3. Data Fetching: The Right Way

With Server Components, data fetching is straightforwardβ€”just fetch in your component. No useEffect, no loading states, no complicated state management.

Basic Pattern

// app/posts/page.tsx - Server Component
async function getPosts() {
  const posts = await db.post.findMany()
  return posts
}

export default async function PostsPage() {
  const posts = await getPosts()
  
  return (
    <div>
      <h1>Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </div>
  )
}

Parallel Data Fetching

Fetch multiple data sources in parallel for better performance:

export default async function Dashboard() {
  // All three fetch in parallel!
  const [user, posts, stats] = await Promise.all([
    getUser(),
    getPosts(),
    getStats()
  ])
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <Stats data={stats} />
    </div>
  )
}
βœ… Performance Win: These requests happen in parallel automatically. In the Pages Router, you'd need complex data fetching logic. Here it's just... normal JavaScript.

4. Caching: Understanding the Layers

Next.js has multiple caching layers. Understanding them is critical for performance.

Controlling Data Cache

// Cache for 1 hour
fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }
})

// Never cache (always fresh)
fetch('https://api.example.com/data', {
  cache: 'no-store'
})

// Cache indefinitely
fetch('https://api.example.com/data', {
  next: { revalidate: false }
})

Revalidating Cache

When data changes, invalidate the cache:

'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string) {
  await db.post.update({ where: { id } })
  
  // Revalidate specific path
  revalidatePath('/posts')
  
  // Or revalidate by tag
  revalidateTag('posts')
}
πŸ’‘ Pro Tip: Use tags for fine-grained cache control. Tag your fetches with next: { tags: ['posts'] }, then revalidate with revalidateTag('posts').

5. Streaming and Loading States

Streaming allows you to show content progressively, improving perceived performance.

Instant Loading States

Create a loading.tsx file next to your page:

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded" />
    </div>
  )
}

Next.js automatically shows this while the page is loading!

Granular Streaming with Suspense

import { Suspense } from 'react'

export default function Page() {
  return (
    <div>
      <h1>Fast content appears immediately</h1>
      
      <Suspense fallback={<div>Loading...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}
βœ… User Experience Win: Users see content immediately instead of staring at a blank page. Slow content streams in when ready!

6. Metadata and SEO

Next.js makes SEO incredibly easy with the Metadata API.

Static Metadata

// app/page.tsx
export const metadata = {
  title: 'My App',
  description: 'Welcome to my app',
  openGraph: {
    title: 'My App',
    images: ['/og-image.jpg']
  }
}

Dynamic Metadata

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug)
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      images: [post.coverImage]
    }
  }
}
πŸ’‘ SEO Pro Tip: Next.js generates all meta tags server-side, so they're perfect for social media crawlers and SEO. No client-side JavaScript needed!

7. Performance Optimizations

Image Optimization

import Image from 'next/image'

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority  // Load immediately
  placeholder="blur"
/>

Font 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>
  )
}

Dynamic Imports

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false
})

8. Advanced Error Handling

Proper error handling is crucial for production apps. The App Router provides multiple levels of error boundaries:

Page-Level Error Boundaries

// app/dashboard/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="error-container">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Global Error Boundary

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <h2>Application Error</h2>
        <p>An unexpected error occurred. Our team has been notified.</p>
        <button onClick={reset}>Reload application</button>
      </body>
    </html>
  )
}

Not Found Handling

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  
  if (!post) {
    notFound() // Triggers not-found.tsx
  }
  
  return <article>{post.content}</article>
}

// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>Post Not Found</h2>
      <p>Could not find the requested blog post.</p>
      <Link href="/blog">View all posts</Link>
    </div>
  )
}
βœ… Error Handling Best Practices:
  • Use error.tsx for recoverable errors (user can retry)
  • Use global-error.tsx for critical application errors
  • Log errors to monitoring service (Sentry, LogRocket)
  • Provide clear, actionable error messages
  • Always offer a way to recover (reset, go back, contact support)

9. Production Checklist

  • ☐ All images use next/image
  • ☐ Fonts optimized with next/font
  • ☐ Heavy components use dynamic imports
  • ☐ Metadata configured for all pages
  • ☐ Error boundaries in place
  • ☐ Loading states with Suspense
  • ☐ Server Actions use validation
  • ☐ Caching strategy defined

Real-World Results

After migrating to App Router:

  • Lighthouse Score: 95+ across all metrics
  • First Contentful Paint: <1.2s (down from 3.5s)
  • Bundle Size: Reduced by 40% with Server Components
  • Developer Velocity: 3x faster (no API layer needed)

Common Pitfalls

❌ Don't:
  • Use "use client" everywhere
  • Fetch data in Client Components with useEffect
  • Skip Server Action validation
  • Put API keys in Client Components
βœ… Do:
  • Default to Server Components
  • Fetch data directly in Server Components
  • Use Server Actions instead of API routes
  • Leverage streaming with Suspense

Conclusion

The App Router isn't just a new routing systemβ€”it's a fundamentally different way of building React applications. The benefits are enormous:

  • Better Performance: Less JavaScript shipped
  • Better SEO: Server-rendered by default
  • Better DX: No API layer needed
  • Better UX: Streaming and progressive enhancement
🎯 Key Takeaway: The App Router makes React development feel like it should have always beenβ€”server-first, progressively enhanced, and ridiculously fast. Embrace the server!

Happy building, and may your Core Web Vitals be ever in your favor! πŸš€