Next.js 16 Intermediate: Optimistic Updates, Middleware, and Streaming for a Slam Dunk Notes App
Coach Alex had a problem. Her existing notes app—built from a beginner tutorial—worked fine for adding and editing playbooks, but the experience felt sluggish. Every time she deleted a note, the whole page reloaded. Searching for drills with tags triggered a network waterfall. She needed an app that felt like a real-time whiteboard. This tutorial transforms that basic notes app into a fast, resilient, production-ready tool—using Next.js 16’s intermediate features.
Before You Begin
- Node.js 18+ and npm
- Next.js 16 (App Router) project already created (we’ll assume you have the basic notes CRUD from the beginner tutorial)
- Familiarity with Server Components, Client Components, and Server Actions
- A SQLite database (we’ll use Prisma for type safety)
- Basic understanding of cookies and middleware
1. Securing the Playbook: Middleware with Cookies
The first upgrade is protecting routes. Without authentication, any player could edit the coach’s notes. We’ll add a simple middleware that checks for a session cookie.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const cookie = request.cookies.get('session')?.value;
// In production, validate a JWT or database session
if (!cookie && !request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/notes/:path*', '/api/:path*'],
};Real-world note: The official Next.js documentation recommends using matcher to avoid running middleware on static assets. Many production apps on GitHub combine this with next-auth or iron-session. For our app, a plain cookie is enough to demonstrate the pattern.
2. Caching the Lineup: Advanced Data Fetching
Our note list doesn’t change every second, so we can cache it. But when the coach adds a new play, the list must update. We’ll use Next.js’s fetch cache options combined with Server Actions that revalidate.
// app/notes/page.tsx (Server Component)
import { prisma } from '@/lib/prisma';
export default async function NotesPage() {
// Fetch with stale-while-revalidate: serve cached data, refresh in background
const notes = await fetch(`${process.env.URL}/api/notes`, {
next: { revalidate: 60 }, // seconds
}).then(res => res.json());
return <NoteList initialNotes={notes} />;
}But wait—this API call is an extra hops. The better approach for Server Components is direct database access with dynamic caching. Use unstable_noStore to opt out of full page cache only for fresh data, or revalidatePath after Server Actions. Here’s the pattern:
import { unstable_noStore as noStore } from 'next/cache';
export default async function NotesPage() {
noStore(); // Ensure dynamic data when needed
const notes = await prisma.note.findMany({
include: { tags: true },
orderBy: { updatedAt: 'desc' },
});
return <NoteList initialNotes={notes} />;
}StackOverflow insight: A common pitfall is forgetting noStore inside a statically rendered layout; the data may never update. Always pair it with revalidation triggers.
3. Optimistic Deletes: Instant Feedback with useOptimistic
The coach wants to swipe a note away without waiting for the server. We’ll use React 19’s useOptimistic hook inside a Client Component.
'use client';
import { useOptimistic, useTransition } from 'react';
interface Note {
id: string;
title: string;
content: string;
}
function NoteList({ initialNotes }: { initialNotes: Note[] }) {
const [notes, setNotes] = useState(initialNotes);
const [optimisticNotes, addOptimisticNote] = useOptimistic(
notes,
(state, action: { type: 'delete'; id: string }) =>
state.filter(n => n.id !== action.id)
);
const [, startTransition] = useTransition();
async function handleDelete(noteId: string) {
startTransition(async () => {
addOptimisticNote({ type: 'delete', id: noteId });
await fetch(`/api/notes?id=${noteId}`, { method: 'DELETE' });
// After server confirms, revalidate
setNotes(prev => prev.filter(n => n.id !== noteId));
});
}
return (
<ul className="space-y-2">
{optimisticNotes.map(note => (
<li key={note.id} className="flex justify-between bg-gray-100 p-2 rounded">
<span>{note.title}</span>
<button onClick={() => handleDelete(note.id)} className="text-red-500">Delete</button>
</li>
))}
</ul>
);
}Engineering blog tip: Vercel’s team suggests combining useOptimistic with Server Actions and revalidatePath for a fully reactive UI. Our fetch API route is a workaround; ideally the Server Action itself revalidates the page.
4. Stream the Huddle: Suspense and Streaming
The notes page has two sections: a list of plays and a detail view. Loading them sequentially is slow. With streaming, we can show a skeleton for the detail while the list renders immediately.
// app/notes/[id]/page.tsx
import { Suspense } from 'react';
import NoteDetail from './NoteDetail';
import NoteListSkeleton from '@/components/NoteListSkeleton';
export default function NotePage({ params }: { params: Promise<{ id: string }> }) {
// Await params (Next.js 16 requirement)
const { id } = await params;
return (
<div className="flex gap-4">
<Suspense fallback={<NoteListSkeleton />}>
{/* This component fetches notes independently */}
<NotesList />
</Suspense>
<Suspense fallback={<div>Loading play...</div>}>
<NoteDetail id={id} />
</Suspense>
</div>
);
}The key is that each Suspense boundary wraps an async component that fetches its own data. The page doesn’t need to await both.
5. Parallel Data Fetching for Faster Loads
When the coach opens the main notes page, we need notes AND tags for filtering. Fetch them in parallel to cut load time in half.
// app/notes/page.tsx
import { prisma } from '@/lib/prisma';
export default async function NotesPage() {
const [notes, tags] = await Promise.all([
prisma.note.findMany({ orderBy: { updatedAt: 'desc' }, take: 50 }),
prisma.tag.findMany(),
]);
return <NotesPageClient notes={notes} tags={tags} />;
}Real-world note: In production systems, you might use Promise.allSettled to handle partial failures gracefully. A common pattern on Dev.to is to wrap each fetch in a try-catch and return fallback data.
6. Common Issues and Solutions
- Stale data after navigation: If you stream a component and then navigate back, the cached version may persist. Use
router.refresh()on client navigation or setdynamic = 'force-dynamic'on the layout. - Hydration mismatch with optimistic state: Make sure the initial server-rendered state matches the client’s first optimistic state. Use
isPendingfromuseTransitionto avoid flashing. - Middleware redirect loops: If the middleware redirects to
/loginbut the login page is also matched, exclude it viamatcher. A StackOverflow thread reports this as the top mistake.
Next Steps
You’ve turned a basic notes app into a performant, secure, and responsive coaching tool. The same patterns apply to dashboards, e-commerce, and social feeds. Next up: add real-time collaboration with WebSockets, or use Incremental Static Regeneration for public playbooks. The court is yours.
Memuat komentar...