Before You Begin
- Node.js 18+ and npm/yarn/pnpm
- Basic Next.js 16 App Router knowledge (know how to create pages, server components, client components, and server actions)
- Familiarity with TypeScript and async/await
- A running Next.js 16 project (or create one with
npx create-next-app@latest)
Project Setup: The Fruit Basket Notes App
We’ll evolve a simple notes app into a production‑grade system. Start by installing key dependencies:
npm install @prisma/client zod
npm install -D prisma
npx prisma init --datasource-provider sqliteSet up Prisma schema (prisma/schema.prisma) with a Note model:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Note {
id String @id @default(cuid())
title String
content String
fruitTag String @default("apple") // apple, banana, cherry...
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Create a lib/prisma.ts singleton to avoid multiple connections during development:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;Run migration and seed a few fruit‑themed notes:
npx prisma migrate dev --name init
npx prisma db seed # (create prisma/seed.ts yourself, or just add manually)1. Route Groups for Clean Separation
Use route groups to organize auth‑related pages (sign‑up, login) separate from main notes pages — without affecting the URL path.
Create folder structure:
app/
├─ (auth)/
│ ├─ login/
│ │ └─ page.tsx
│ ├─ register/
│ │ └─ page.tsx
│ └─ layout.tsx
├─ (main)/
│ ├─ notes/
│ │ ├─ page.tsx
│ │ ├─ [id]/
│ │ │ └─ page.tsx
│ │ └─ layout.tsx
│ └─ layout.tsx
├─ layout.tsx
└─ page.tsxThe (auth) group uses a minimal layout (just a centered card). The (main) group uses a layout with sidebar navigation. This way, /login is not nested under /notes, but the code stays logically separated.
In app/(auth)/layout.tsx, return only <div className="auth-container">{children}</div>. In app/(main)/layout.tsx, wrap children with a sidebar showing fruit categories.
2. Database Queries with Server Components (Caching & Revalidation)
Fetch notes list directly in a server component. Use the fetch option next: { revalidate } to control caching — but with Prisma, we rely on Next.js’s built‑in unstable_noStore or fetch cache.
For dynamic data that changes often, disable caching:
// app/(main)/notes/page.tsx
import { prisma } from '@/lib/prisma';
import { unstable_noStore as noStore } from 'next/cache';
export default async function NotesPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
noStore(); // Ensure fresh data every request
const { q, page } = await searchParams;
const currentPage = Number(page) || 1;
const pageSize = 10;
const where = q
? { OR: [{ title: { contains: q } }, { content: { contains: q } }] }
: {};
const [notes, total] = await Promise.all([
prisma.note.findMany({
where,
skip: (currentPage - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.note.count({ where }),
]);
return (
<div>
<SearchForm />
<ul>
{notes.map((note) => (
<li key={note.id}>
<span className="fruit-icon-{note.fruitTag}" />
{note.title}
</li>
))}
</ul>
<Pagination currentPage={currentPage} totalPages={Math.ceil(total / pageSize)} />
</div>
);
}Caching strategy: noStore() tells Next.js to always fetch fresh data. For pages that rarely change, use export const dynamic = 'force-static' and revalidate = 3600. A common pattern seen in production Next.js repos on GitHub is to combine noStore for dynamic routes and incremental static regeneration (ISR) for listing pages that don’t need real‑time accuracy.
3. Server Actions with Zod Validation and Revalidation
Create a server action to add a new note. Validate the input with Zod, then revalidate the notes list path so the UI updates.
app/(main)/actions.ts:
'use server';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
const noteSchema = z.object({
title: z.string().min(1, 'Title cannot be empty').max(100),
content: z.string().min(1),
fruitTag: z.enum(['apple', 'banana', 'cherry', 'grape', 'orange']),
});
export async function createNote(formData: FormData) {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
fruitTag: formData.get('fruitTag'),
};
const result = noteSchema.safeParse(raw);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await prisma.note.create({ data: result.data });
revalidatePath('/notes');
return { success: true };
}In the client component (NoteForm.tsx), use useActionState (React 19) or useAction from react-dom. Note that the official Next.js documentation recommends using useActionState when you need to handle server action errors on the client.
'use client';
import { useActionState } from 'react';
import { createNote } from './actions';
export function NoteForm() {
const [state, formAction] = useActionState(createNote, { errors: {} });
return (
<form action={formAction}>
<input name="title" placeholder="Mango Notes" />
{state.errors?.title && <p className="error">{state.errors.title}</p>}
<textarea name="content" placeholder="Write your fruit thoughts..." />
<select name="fruitTag">
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</select>
<button type="submit">Plant Note</button>
</form>
);
}4. Parallel Routes for Side-by-Side Editing
Implement a split‑screen view: on the left, list of notes; on the right, a note editor or details panel. Parallel routes allow rendering two independent segments that can each have their own loading and error states.
Structure:
app/(main)/notes/
├─ @list/
│ └─ page.tsx
├─ @detail/
│ ├─ page.tsx (default, empty state)
│ └─ [id]/
│ └─ page.tsx
├─ page.tsx (layout with slots)
└─ layout.tsxIn app/(main)/notes/layout.tsx:
export default function NotesLayout({
children,
list,
detail,
}: {
children: React.ReactNode;
list: React.ReactNode;
detail: React.ReactNode;
}) {
return (
<div className="flex gap-4">
<aside className="w-1/3">{list}</aside>
<main className="w-2/3">{detail}</main>
</div>
);
}The @list slot renders the note listing (with search, pagination). The @detail slot renders the selected note or a placeholder. Navigate between notes using <Link> with ?noteId=... (or catch‑all params). This pattern, often seen in engineering blogs from companies like Vercel, reduces layout re‑renders and improves perceived performance.
5. Intercepting Routes for a Modal Editor
Want to open a note editor as a modal on the notes list, but keep the direct URL accessible? Use intercepting routes with (.).
Create app/(main)/notes/(.)edit/[id]/page.tsx. This renders the edit form as a modal when navigating from the notes list (e.g., clicking “Edit”). A direct visit to /notes/edit/[id] renders the full page version. The modal uses a client component that includes a backdrop and close button that calls router.back().
This pattern avoids rebuilding the entire list page and is a standard UX pattern for productivity apps (see popular open‑source projects like Linear).
6. Metadata API for SEO and Social Sharing
Each page can export a generateMetadata function for dynamic titles and descriptions. Use the fruitTag to generate unique OG images.
// app/(main)/notes/[id]/page.tsx
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const note = await prisma.note.findUnique({ where: { id } });
if (!note) return { title: 'Note not found' };
return {
title: `${note.title} - Fruit Notes`,
description: note.content.slice(0, 160),
openGraph: {
images: [`/api/og?fruit=${note.fruitTag}`],
},
};
}Many developers on StackOverflow miss that metadata must be exported from a server component — it cannot be used in client components. Always place generateMetadata in page.tsx or layout.tsx.
Common Issues and Solutions
1. Prisma not found at build time – Ensure @prisma/client is in dependencies, and run prisma generate after schema changes. Use prisma.js singleton from the official Next.js example.
2. params is not a plain object – In Next.js 16, route params are Promise. Always await them inside the handler, as shown in the technical standard. Forgetting leads to params.then is not a function errors.
3. Server Actions not revalidating – Ensure you call revalidatePath('/notes') or revalidateTag('notes'). Also, client components using useActionState must be wrapped in a <Suspense> boundary to see the loading state properly.
4. Parallel route slot not rendering – If a slot file returns null, it will still cause a 404 by default. Always provide a default.tsx for each slot that returns a fallback component or null with notFound() conditional.
5. Metadata not appearing – Verify the page is a server component (no 'use client'). Dynamic metadata requires the page to be server‑rendered.
This tutorial bridges the beginner and advanced by adding real‑world patterns: database integration, validation, advanced routing, caching control, and SEO. The fruit theme keeps it light while the code matches production standards used in enterprise Next.js applications.

Memuat komentar...