Frontend Development

Next.js 14 App Router: Complete Developer Guide

Master the new App Router, Server Components, and data fetching patterns. Everything you need to build production-ready Next.js applications.

Harsh RastogiHarsh Rastogi
Dec 12, 202415 min
Next.jsReactTypeScriptFrontendSSR

The Next.js Revolution

Next.js 14's App Router represents the biggest shift in React development since hooks. Having built production applications with both the Pages Router and App Router — including parts of EduFly and internal tools at Modelia.ai — I can share practical insights that go beyond the docs.

The fundamental change is this: components are Server Components by default. They run on the server, have zero JavaScript shipped to the client, and can directly access databases, file systems, and APIs. This isn't just a performance optimization — it's a new mental model for building React applications.

Server Components: The Default

In the App Router, every component is a Server Component unless you explicitly opt into client-side rendering with "use client". This means you can do things that were impossible before:

typescript
// app/dashboard/page.tsx — This is a Server Component
// It runs ONLY on the server. Zero JS sent to the browser for this code.
import { prisma } from '@/lib/db';

async function DashboardPage() {
  // Direct database access — no API route needed!
  const stats = await prisma.analytics.aggregate({
    _sum: { revenue: true },
    _count: { orders: true },
    _avg: { responseTime: true },
  });

  const recentOrders = await prisma.order.findMany({
    take: 10,
    orderBy: { createdAt: 'desc' },
    include: { customer: true },
  });

  return (
    <div>
      <StatsCards stats={stats} />
      <RecentOrdersTable orders={recentOrders} />
    </div>
  );
}

export default DashboardPage;

No useEffect, no loading states for initial data, no API routes — the data is fetched at the server and the HTML is streamed to the client. The customer never downloads or executes the Prisma client. This reduces the bundle size for this page to almost nothing.

At Modelia.ai, our admin dashboard loads 3x faster with Server Components because we eliminated 400KB of client-side data fetching logic.

When to Use Client Components

Add "use client" only when you need browser-specific capabilities:

typescript
"use client";

import { useState, useEffect } from 'react';

// This component needs interactivity — it must be a Client Component
function ProductSearch({ initialProducts }: { initialProducts: Product[] }) {
  const [query, setQuery] = useState('');
  const [products, setProducts] = useState(initialProducts);

  // Client-side filtering for instant feedback
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      <ProductGrid products={filtered} />
    </div>
  );
}

The key insight: push "use client" as far down the tree as possible. The search input needs interactivity, but the page layout, header, and sidebar don't. Keep those as Server Components.

Data Fetching Patterns

Parallel Data Fetching

The biggest performance mistake in the App Router is sequential data fetching. Each await blocks the next:

typescript
// BAD: Sequential — total time = sum of all fetches
async function Page() {
  const users = await getUsers();          // 200ms
  const analytics = await getAnalytics();  // 300ms
  const orders = await getOrders();        // 150ms
  // Total: 650ms
}

// GOOD: Parallel — total time = longest fetch
async function Page() {
  const [users, analytics, orders] = await Promise.all([
    getUsers(),          // 200ms
    getAnalytics(),      // 300ms (longest)
    getOrders(),         // 150ms
  ]);
  // Total: 300ms — 2x faster!
}

At Asynq.ai, switching from sequential to parallel data fetching on our hiring dashboard reduced page load from 1.8s to 600ms.

Streaming with Suspense

Not all data is equal. The page header and navigation should appear instantly; analytics charts can load progressively. Suspense boundaries let you stream parts of the page as they become ready:

typescript
import { Suspense } from 'react';

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      {/* Nav loads instantly — no async data */}
      <Sidebar />

      <main>
        {/* Stats appear first */}
        <Suspense fallback={<StatsSkeleton />}>
          <StatsSection />
        </Suspense>

        {/* Charts stream in when ready */}
        <Suspense fallback={<ChartsSkeleton />}>
          <AnalyticsCharts />
        </Suspense>

        {/* Table streams in last */}
        <Suspense fallback={<TableSkeleton />}>
          <RecentActivityTable />
        </Suspense>
      </main>
    </div>
  );
}

The user sees useful content within 200ms, even if the full page takes 2 seconds to load. Perceived performance is what matters.

SEO with the Metadata API

The App Router makes SEO a first-class concern. No more components or third-party libraries:

typescript
import { Metadata } from 'next';
import { prisma } from '@/lib/db';

// Static metadata
export const metadata: Metadata = {
  title: 'Dashboard | EduFly',
  description: 'AI-powered school analytics dashboard',
  openGraph: {
    title: 'EduFly Dashboard',
    images: ['/og-dashboard.png'],
  },
};

// Dynamic metadata — for pages with dynamic content
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const product = await prisma.product.findUnique({
    where: { slug: params.slug },
  });

  return {
    title: product?.name || 'Product Not Found',
    description: product?.description,
    openGraph: {
      title: product?.name,
      images: [product?.imageUrl || '/default-og.png'],
    },
  };
}

This was a major improvement for EduFly. With the Pages Router, our blog posts had inconsistent OG tags because the component sometimes didn't update on client-side navigation.

Route Handlers (API Routes)

API routes in the App Router use standard Web Request/Response APIs:

typescript
// app/api/webhook/shopify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const hmac = request.headers.get('x-shopify-hmac-sha256');

  // Verify Shopify webhook signature
  const hash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(body)
    .digest('base64');

  if (hash !== hmac) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const payload = JSON.parse(body);
  await processShopifyWebhook(payload);

  return NextResponse.json({ received: true });
}

At Modelia.ai, our Shopify extension uses these route handlers for webhook processing, ensuring that product updates, order events, and app installations are handled securely.

Server Actions

Server Actions replace API routes for form submissions and mutations. They're functions that run on the server but can be called directly from Client Components:

typescript
// actions/contact.ts
"use server";

import { z } from 'zod';
import { prisma } from '@/lib/db';

const ContactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  message: z.string().min(10),
});

export async function submitContactForm(formData: FormData) {
  const parsed = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }

  await prisma.contactSubmission.create({ data: parsed.data });
  await sendNotificationEmail(parsed.data);

  return { success: true };
}

Deployment at Scale

At Modelia.ai, we deploy Next.js applications on AWS with:

  • Vercel for frontend applications (automatic edge deployment, preview deploys per PR)
  • AWS ECS for API services behind the Next.js app
  • CloudFront CDN for static assets with 1-year cache headers
  • Route 53 for DNS with health checks and failover

For EduFly, which needed to run in the Indian region specifically (data residency requirements for school data), we use a self-hosted Next.js deployment on ECS in ap-south-1.

Key Takeaways

  • Default to Server Components — add "use client" only when you need interactivity or browser APIs
  • Push "use client" down the tree — keep layouts and data-fetching pages as Server Components
  • Use parallel data fetching with Promise.all — sequential awaits are the biggest hidden performance cost
  • Streaming with Suspense improves perceived performance — show useful content while slow data loads
  • The Metadata API makes SEO a first-class concern — dynamic OG tags just work
  • Server Actions replace simple API routes — fewer files, less boilerplate, same security
  • Invest in skeleton loading states — they make streaming feel polished, not janky
  • The patterns from building EduFly and working at Asynq.ai apply directly — the App Router doesn't change what you build, it changes how you build it

Share this article

Harsh Rastogi - Full Stack Engineer

Harsh Rastogi

Full Stack Engineer

Full Stack Engineer building production AI systems at Modelia. Previously at Asynq and Bharat Electronics Limited. Published researcher.

Connect on LinkedIn

Follow me for more insights on software engineering, system design, and career growth.

View Profile