Skip to content

Next.js Essentials

Patterns and conventions for the Next.js 14+ App Router used across Sartiq frontend projects.


App Router Fundamentals

The App Router uses a file-system based router where folders define routes and special files define UI.

Route Files

File Purpose
page.tsx Unique UI for a route segment, makes route publicly accessible
layout.tsx Shared UI that wraps child segments (persists across navigations)
loading.tsx Instant loading state shown while segment content loads
error.tsx Error boundary scoped to a segment
not-found.tsx UI for notFound() calls or unmatched routes
template.tsx Like layout but re-mounts on navigation (no state persistence)

Basic Routing Structure

app/
├── layout.tsx          # Root layout (required)
├── page.tsx            # Home route (/)
├── products/
│   ├── page.tsx        # /products
│   ├── [id]/
│   │   └── page.tsx    # /products/:id
│   └── loading.tsx     # Loading state for /products/*
└── (dashboard)/        # Route group (no URL impact)
    ├── layout.tsx      # Shared dashboard layout
    ├── settings/
    │   └── page.tsx    # /settings
    └── analytics/
        └── page.tsx    # /analytics

Layouts

Layouts wrap child segments and preserve state across navigations:

// app/layout.tsx — Root layout (required)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header>
          <Nav />
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

Loading and Error States

// app/products/loading.tsx
export default function Loading() {
  return <ProductListSkeleton />;
}

// app/products/error.tsx
"use client"; // Error boundaries must be client components

import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Server vs Client Components

All components in the App Router are Server Components by default. Add "use client" only when you need browser APIs, event handlers, or React hooks that manage state.

Decision Table

Need Component Type
Fetch data, access backend resources Server
Static content, no interactivity Server
useState, useEffect, useRef Client
Event handlers (onClick, onChange) Client
Browser APIs (localStorage, IntersectionObserver) Client
Third-party libs that use hooks Client

Interleaving Pattern

Server Components can import and render Client Components, but not the reverse. Pass Server Components to Client Components as children or props:

// app/dashboard/page.tsx — Server Component
import { Sidebar } from "@/components/Sidebar"; // Client
import { UserStats } from "@/components/UserStats"; // Server

export default async function DashboardPage() {
  const stats = await getStats();

  return (
    <Sidebar>
      {/* Server Component passed as children to Client Component */}
      <UserStats data={stats} />
    </Sidebar>
  );
}
// components/Sidebar.tsx — Client Component
"use client";

import { useState } from "react";

export function Sidebar({ children }: { children: React.ReactNode }) {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <aside className={collapsed ? "w-16" : "w-64"}>
      <button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
      {children}
    </aside>
  );
}

Preventing Accidental Client Bundling

Use the server-only package to prevent server code from being imported into Client Components:

// lib/db.ts
import "server-only";
import { prisma } from "./prisma";

export async function getUsers() {
  return prisma.user.findMany();
}

If a Client Component imports this file, the build will fail with a clear error.


Data Fetching

Server Component Fetch

Server Components can use async/await directly — no hooks, no client-side waterfalls:

// app/products/page.tsx
interface Product {
  id: string;
  name: string;
  price: number;
}

export default async function ProductsPage() {
  const products: Product[] = await fetch(
    "https://api.sartiq.com/v1/products",
    { next: { revalidate: 60 } } // Revalidate every 60 seconds
  ).then((res) => res.json());

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.name}  ${product.price}
        </li>
      ))}
    </ul>
  );
}

Direct Database/ORM Access

Server Components can access backend resources directly, with no API layer needed:

import { prisma } from "@/lib/prisma";

export default async function UsersPage() {
  const users = await prisma.user.findMany({
    select: { id: true, name: true, email: true },
    orderBy: { createdAt: "desc" },
  });

  return <UserTable users={users} />;
}

Parallel vs Sequential Fetching

// Sequential — each request waits for the previous one
async function SequentialPage() {
  const user = await getUser(); // Starts first
  const posts = await getPosts(user.id); // Waits for user
  return <Profile user={user} posts={posts} />;
}

// Parallel — fire all requests at once
async function ParallelPage() {
  const [user, products, analytics] = await Promise.all([
    getUser(),
    getProducts(),
    getAnalytics(),
  ]);

  return (
    <Dashboard user={user} products={products} analytics={analytics} />
  );
}

Request Memoization

React automatically deduplicates fetch calls with the same URL and options within a single server render. This means you can call the same fetch in multiple components without worrying about redundant requests:

// Both components call getUser() — React deduplicates to one request
async function UserName() {
  const user = await getUser(); // fetch('/api/user')
  return <h1>{user.name}</h1>;
}

async function UserAvatar() {
  const user = await getUser(); // Same fetch — deduplicated
  return <img src={user.avatar} alt={user.name} />;
}

Memoization scope

Request memoization only applies during a single server render pass. It does not persist across requests or pages.


Metadata & SEO

Static Metadata

Export a metadata object from a layout.tsx or page.tsx:

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    template: "%s | Sartiq",
    default: "Sartiq — AI Product Photography",
  },
  description: "Generate studio-quality product photos with AI",
  openGraph: {
    type: "website",
    siteName: "Sartiq",
  },
};

Dynamic Metadata

Use generateMetadata when metadata depends on route params or fetched data:

// app/products/[id]/page.tsx
import type { Metadata } from "next";

interface Props {
  params: Promise<{ id: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      images: [{ url: product.imageUrl, width: 1200, height: 630 }],
    },
  };
}

export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  const product = await getProduct(id);
  return <ProductDetail product={product} />;
}

Sharing Data Between generateMetadata and Page

Use React cache() to deduplicate the data fetch:

import { cache } from "react";

const getProduct = cache(async (id: string) => {
  return prisma.product.findUniqueOrThrow({ where: { id } });
});

// Both generateMetadata and the page component call getProduct —
// React cache ensures the query runs only once.

Image Optimization

next/image automatically serves optimized, responsive images in modern formats (WebP/AVIF).

Basic Usage

import Image from "next/image";
import heroImg from "@/public/hero.jpg"; // Static import

export function Hero() {
  return (
    <Image
      src={heroImg}
      alt="Product showcase"
      priority // Preload above-the-fold images
      placeholder="blur" // Auto blur placeholder from static import
    />
  );
}

Remote Images

Configure allowed domains in next.config.ts:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "**.sartiq.com",
      },
      {
        protocol: "https",
        hostname: "cdn.shopify.com",
      },
    ],
  },
};

export default nextConfig;

Fill Mode for Unknown Dimensions

// Parent must have position: relative and defined dimensions
<div className="relative h-64 w-full">
  <Image
    src={product.imageUrl}
    alt={product.name}
    fill
    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    className="object-cover rounded-lg"
  />
</div>
Prop When to Use
priority Above-the-fold images (hero, LCP element)
placeholder="blur" Static imports or when blurDataURL is provided
fill Unknown or responsive dimensions (parent must be relative)
sizes Always pair with fill or responsive layouts to avoid oversized downloads

Font Optimization

next/font automatically self-hosts fonts with zero layout shift.

Google Fonts

// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});

const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

Local Fonts

import localFont from "next/font/local";

const calSans = localFont({
  src: "./fonts/CalSans-SemiBold.woff2",
  display: "swap",
  variable: "--font-cal",
});

Tailwind Integration

Use CSS variables to wire fonts into Tailwind:

// tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  theme: {
    extend: {
      fontFamily: {
        sans: ["var(--font-inter)", "system-ui", "sans-serif"],
        mono: ["var(--font-mono)", "monospace"],
        cal: ["var(--font-cal)", "sans-serif"],
      },
    },
  },
};

export default config;

next/link prefetches linked routes in the background when they enter the viewport:

import Link from "next/link";

export function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/products">Products</Link>
      <Link href="/products/new" prefetch={false}>
        {/* Disable prefetch for rarely visited pages */}
        New Product
      </Link>
    </nav>
  );
}
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";

interface NavLinkProps {
  href: string;
  children: React.ReactNode;
}

export function NavLink({ href, children }: NavLinkProps) {
  const pathname = usePathname();
  const isActive = pathname === href || pathname.startsWith(`${href}/`);

  return (
    <Link
      href={href}
      className={cn(
        "px-3 py-2 rounded-md text-sm font-medium transition-colors",
        isActive
          ? "bg-primary text-primary-foreground"
          : "text-muted-foreground hover:text-foreground"
      )}
    >
      {children}
    </Link>
  );
}

Programmatic Navigation

"use client";

import { useRouter } from "next/navigation";

export function LogoutButton() {
  const router = useRouter();

  async function handleLogout() {
    await fetch("/api/auth/logout", { method: "POST" });
    router.push("/login");
    router.refresh(); // Invalidate all cached server data
  }

  return <button onClick={handleLogout}>Logout</button>;
}
Method Purpose
router.push(url) Navigate to a new URL (adds to history)
router.replace(url) Navigate without adding history entry
router.back() Go back one step
router.refresh() Re-fetch server data for current route without hard reload
router.prefetch(url) Manually prefetch a route