
Author: Julius Adelekun
Editor: Vahe Aslanyan
Welcome to Modern Full-Stack with Next.js: Server Components, Streaming, and the Vercel AI SDK. This ebook is designed for developers who are already familiar with React and are looking to deepen their understanding of Next.js for modern full-stack development. The web development landscape is constantly shifting, and Next.js has positioned itself at the forefront of this evolution, particularly with the introduction of the App Router and React Server Components.
The goal of this comprehensive guide is to equip you with the knowledge and practical skills necessary to build high-performance, scalable, and AI-powered full-stack applications. We will explore the paradigm shift brought about by Next.js 14+, moving away from traditional client-side rendering and embracing a server-first approach that significantly enhances both user experience and developer productivity.
Throughout this ebook, you will learn how to leverage React Server Components to reduce client-side JavaScript and improve initial load times. We will delve into the intricacies of streaming UI, demonstrating how to provide immediate visual feedback to users even while complex data fetching operations are underway. You will also master the art of data management using Server Actions for seamless mutations and Route Handlers for robust API endpoints.
Furthermore, we will explore the exciting frontier of artificial intelligence integration using the Vercel AI SDK. You will discover how to build interactive chatbots, implement streaming AI responses, and utilize advanced techniques like Retrieval-Augmented Generation (RAG) to ground your AI applications in factual data. Finally, we will cover essential deployment strategies, performance optimization techniques, and security best practices to ensure your applications are ready for production. By the end of this journey, you will be well-prepared to architect and build the next generation of intelligent web applications.
We have traversed a significant amount of ground in Modern Full-Stack with Next.js: Server Components, Streaming, and the Vercel AI SDK. From the foundational concepts of the App Router and React Server Components to the advanced implementation of AI features and production deployment strategies, you now possess a comprehensive understanding of modern web development with Next.js.
Let’s briefly recap the key concepts we’ve covered. We began by exploring how React Server Components fundamentally change the rendering paradigm, allowing for reduced client-side JavaScript and improved performance. We then examined how streaming UI, powered by React
Suspense, enhances perceived performance by delivering content progressively. We also learned how to manage data effectively using Server Actions for seamless mutations and Route Handlers for building robust APIs.
In the realm of artificial intelligence, we discovered how the Vercel AI SDK simplifies the integration of large language models, enabling features like streaming chat responses and function calling. We also explored Retrieval-Augmented Generation (RAG) as a crucial technique for grounding AI responses in factual, domain-specific data. Finally, we discussed the importance of deploying to optimized platforms like Vercel, monitoring Core Web Vitals, and adhering to security best practices.
The knowledge you’ve gained here is just the beginning. The Next.js ecosystem and the field of AI are evolving rapidly. I encourage you to continue exploring, experimenting, and building. Dive into the official documentation, participate in the community, and apply these concepts to your own projects. The future of full-stack development is intelligent, performant, and highly interactive, and you are now equipped to be a part of it. Happy coding!
Next.js has consistently evolved to meet the demands of modern web development, and the introduction of the App Router in Next.js 13 (and further stabilized in Next.js 14) marks a significant paradigm shift. Prior to the App Router, Next.js applications primarily relied on the Pages Router, which was built around the concept of file-system-based routing where each file in the pages directory corresponded to a route. While effective, the Pages Router had limitations, particularly in how it handled data fetching, rendering environments, and the integration of server-side logic with client-side interactivity.
The App Router, built on top of React Server Components (RSC), was introduced to address these challenges by providing a more flexible, performant, and developer-friendly approach to building full-stack applications. Its core benefits include:
React Server Components (RSC) are a groundbreaking feature that fundamentally changes how React applications are built and rendered. Unlike traditional React components, which are always rendered on the client-side (after being pre-rendered on the server in SSR/SSG scenarios), Server Components are designed to render exclusively on the server. This distinction is crucial for understanding the power and implications of the App Router.
To fully grasp RSC, it’s essential to compare them with their client-side counterparts, which we now refer to as Client Components. The table below highlights the key differences:
FeatureReact Server Components (RSC)React Client ComponentsExecution EnvironmentServer onlyClient (browser) after initial server render (hydration)InteractivityNo interactivity (no useState , useEffect, event handlers)Full interactivity ( useState , useEffect, event handlers)Data FetchingDirect database/API access, synchronous awaitClient-side fetching (e.g., fetch in useEffect)Bundle SizeZero JavaScript shipped to the clientJavaScript shipped to the client for hydration and interactivityAccess to Server ResourcesDirect access to file system, databases, environment variablesNo direct access to server resourcesRenderingRendered once on the server, output is HTML/JSONRendered on client, re-renders on state/prop changesDirectiveDefault (no directive needed)‘use client’ at the top of the file
Server Components play a pivotal role in enhancing both performance and developer experience:
The decision of when to use a Server Component versus a Client Component is fundamental to building effective Next.js applications with the App Router. It’s not about choosing one over the other, but understanding how they complement each other to create a seamless user experience.
Server Components are ideal for:
Example: A component that fetches and displays a list of articles from a database.
// app/articles/page.tsx (Server Component by default)
import { getArticles } from '../../lib/data';
export default async function ArticlesPage() {
const articles = await getArticles(); // Data fetching on the server
return (
<div>
<h1>Latest Articles</h1>
<ul>
{articles.map((article) = (
<li key={article.id}>{article.title}</li>
))}
</ul>
</div>
);
}
Client Components are necessary for:
Example: A counter component with increment/decrement buttons.
// app/components/Counter.tsx
'use client'; // This directive marks it as a Client Component
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() ⇒ setCount(count + 1)}>Increment</button>
<button onClick={() ⇒ setCount(count - 1)}>Decrement</button>
</div>
);
}
The true power of the App Router lies in the ability to seamlessly compose Server and Client Components. A common pattern is to fetch data in a Server Component and then pass that data as props to a Client Component for interactivity.
Scenario: A product page that displays product details (fetched on the server) and allows users to add the product to a cart (client-side interactivity).
// lib/data.ts (Server-side data fetching utility)
export async function getProduct(id: string) \{
// Simulate database fetch
return new Promise((resolve) ⇒ \{
setTimeout(() ⇒ \{
resolve(\{ id, name: ˋProduct \$\{id\}’, price: 29.99, description: 'A
\}, 500);
\}) ;
\}
// app/product/[id]/page.tsx (Server Component)
import \{ getProduct \} from '../../../lib/data';
import AddToCartButton from '../../components/AddToCartButton'; // Client
export default async function ProductPage(\{ params \}: \{ params: \{ id: str:
const product = await getProduct(params.id);
return (
<div>
<h1>\{product.name\}</h1>
<p>\{product.description\}</p>
<p>Price: \$\{product.price\}</p>
<AddToCartButton productId=\{product.id\} /> \{/* Server Component renı
</div>
);
\}
// app/components/AddToCartButton.tsx (Client Component)
'use client';
import \{ useState \} from 'react';
export default function AddToCartButton(\{ productId \}: \{ productId: strin!
const [isAdding, setIsAdding] = useState(false);
const handleAddToCart = () ⇒ \{
setIsAdding(true);
// Simulate API call to add to cart
setTimeout(() ⇒ \{
alert(ˋAdded product \$\{productId\} to cart!ˋ);
setIsAdding(false);
\}, 1000);
\};
return (
<button onClick={handleAddToCart} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
In this example, ProductPage is a Server Component that fetches product data. It then renders the AddToCartButton, which is a Client Component responsible for handling user interaction. The productId is passed as a prop from the Server Component to the Client Component.
One of the most compelling features of React Server Components is the ability to perform data fetching directly within the component itself, using native JavaScript async/await. This eliminates the need for getServerSideProps, getStaticProps, or getInitialProps from the Pages Router, simplifying data management significantly.
Next.js with the App Router encourages a direct and intuitive approach to data fetching:
// lib/users.ts
export async function getUsers() {
// In a real application, this would be a database query or an internal
const res = await fetch('<https://jsonplaceholder.typicode.com/users>');
if (!res.ok) {
throw new Error('Failed to fetch users');
}
return res.json();
}
// app/users/page.tsx
import { getUsers } from '../../lib/users';
export default async function UsersPage() {
const users = await getUsers();
return (
<div>
<h1>Users</h1>
<ul>
{users.map((user: any) = (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Next.js extends the native fetch API with powerful caching and revalidation options, allowing fine-grained control over data freshness:
// lib/products.ts
export async function getProducts() {
const res = await fetch('<https://api.example.com/products>', {
next: { revalidate: 60 }, // Revalidate data every 60 seconds
});
if (!res.ok) {
throw new Error('Failed to fetch products');
}
return res.json();
}
The App Router introduces a new, opinionated project structure that leverages file-system conventions to define routes, layouts, and other UI components. Understanding these conventions is key to building Next.js applications effectively.
Consider a simple blog application. Its structure might look like this:
app/
layout.tsx // Root layout for the entire application
page.tsx // Home page
blog/
layout.tsx // Layout specific to blog routes
page.tsx // Blog listing page
[slug]/
page.tsx // Dynamic blog post page (e.g., /blog/my-first-po!
loading.tsx // Loading UI for a blog post
error.tsx // Error UI for a blog post
dashboard/
layout.tsx // Layout specific to dashboard routes
page.tsx // Dashboard home page
settings/
page.tsx // Dashboard settings page
api/
route.ts // Route Handler for API endpoints (e.g., /api/use।
global.css
favicon.ico
This structure demonstrates how layouts can be nested, and how specific UI states (loading, error) can be defined at different levels of the routing hierarchy. The api/route.ts file signifies the location for Route Handlers, which are the App Router’s equivalent of API Routes from the Pages Router.
By understanding these foundational concepts of the App Router and React Server Components, developers can begin to leverage the full power of Next.js for building modern, high-performance, and scalable full-stack applications.
[1] Vercel. (n.d.). Next.js Documentation: App Router. Retrieved from https://nextjs.org/docs/app [2] React. (n.d.). React Server Components. Retrieved from https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components [3] Vercel. (n.d.). Next.js Documentation: Data Fetching. Retrieved from https://nextjs.org/docs/app/building-your-application/data-fetching
In the context of server-rendered React applications, hydration is the process by which the client-side JavaScript takes over the static HTML content sent from the server, making it interactive. When a user requests a page, the server renders the React components into HTML and sends this HTML to the browser. This allows the user to see the content almost immediately, improving perceived performance and SEO. However, this initial HTML is static; it doesn’t respond to user interactions like clicks or input changes.
Hydration is the step where the client-side React code attaches event listeners and re-renders the components with their initial state, effectively turning the static HTML into a fully interactive React application. During hydration, React attempts to match the server-rendered HTML with the client-side component tree. If the two trees don’t match (e.g., due to different rendering logic on the server and client, or content inserted by a browser extension), it can lead to a hydration mismatch error, which can cause performance issues or unexpected behavior.
The “use client” directive is a crucial mechanism in the Next.js App Router that explicitly marks a module and all its imported dependencies as Client Components. By default, all components within the app directory are considered Server Components. This directive signals to the Next.js build system that the code within this file (and any modules it imports that don’t also have a “use client” directive) should be bundled and sent to the client for execution.
The primary purpose of “use client” is to enable client-side interactivity. Any component that needs to use React Hooks like useState, useEffect, useRef, or browser-specific APIs (e.g., window , localStorage ) must be a Client Component and thus marked with “use client”.
// app/components/InteractiveButton.tsx
'use client'; // Marks this as a Client Component
import { useState } from 'react';
export default function InteractiveButton() {
const [clicked, setClicked] = useState(false);
return (
<button onClick={() ⇒ setClicked(!clicked)}>
{clicked ? 'Clicked!' : 'Click me'}
</button>
);
}
One of the most common patterns in the App Router is to fetch data in a Server Component and then pass that data down to Client Components that require it for interactivity or display. This allows you to leverage the performance benefits of server-side data fetching while still providing a rich, interactive user experience.
The most straightforward way to pass data from a Server Component to a Client Component is through props. A Server Component can render a Client Component and pass any serializable data as props. This data will be serialized on the server and then deserialized on the client during hydration.
Example: Displaying user data fetched on the server in an interactive client component.
// lib/user.ts (Server-side data fetching)
export async function getUser(id: string) {
// Simulate database fetch
return { id, name: 'Alice', email: 'alice@example.com' };
}
// app/profile/[id]/page.tsx (Server Component)
import { getUser } from '../../../lib/user';
import UserProfileCard from '../../components/UserProfileCard'; // Client
export default async function ProfilePage({ params }: { params: { id: str:
const user = await getUser(params.id);
return (
<div>
<h1>User Profile</h1>
<UserProfileCard user={user} /> {/* Pass user data as prop */}
</div>
);
}
// app/components/UserProfileCard.tsx (Client Component)
'use client';
import { useState } from 'react';
interface User { id: string; name: string; email: string; }
export default function UserProfileCard({ user }: { user: User }) {
const [showEmail, setShowEmail] = useState(false);
return (
<div style={{ border: '1px solid #ccc', padding: '1rem' }}>
<h2>{user.name} </h2>
<p>ID: {user.id}</p>
<button onClick={() ⇒ setShowEmail(!showEmail)}>
{showEmail ? 'Hide Email' : 'Show Email'}
</button>
{showEmail && <p>Email: {user.email}</p>}
</div>
);
}
While React Context is primarily a client-side feature for managing global state, it can be used in a limited way to pass data from Server Components to Client Components. The key is that the Context Provider must be a Client Component, and the data passed to it must be serializable.
Example: A theme provider that sets a theme based on a server-fetched user preference.
// app/providers/ThemeProvider.tsx (Client Component)
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) = void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefine।
export function ThemeProvider({ children, initialTheme }: { children: Rear
const [theme, setTheme] = useState<Theme>(initialTheme);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context .= undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// app/layout.tsx (Server Component - wraps Client Component Provider)
import { ThemeProvider } from './providers/ThemeProvider';
async function getInitialTheme() {
// Simulate fetching user preference from server
return 'dark';
}
export default async function RootLayout({ children }: { children: React.l
const initialTheme = await getInitialTheme();
return (
<html lang="en">
<body>
<ThemeProvider initialTheme={initialTheme}> {/* Client Component |
{children}
</ThemeProvider>
</body>
</html>
);
}
// app/components/ThemeToggle.tsx (Client Component - consumes context)
'use client';
import { useTheme } from '../providers/ThemeProvider';
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() ⇒ setTheme(theme == 'light' ? 'dark' : 'light').
Toggle Theme ({theme})
</button>
);
}
In this pattern, the Server Component ( RootLayout ) fetches the initialTheme and passes it as a prop to the ThemeProvider (a Client Component). The ThemeProvider then manages the theme state on the client-side using useState and makes it available to descendant Client Components via useContext.
It’s common to have components that are used by both Server and Client Components. These are often UI primitives like buttons, cards, or typography components that don’t have any inherent interactivity or server-side data fetching needs. The key strategy here is to keep them as Server Components by default and only mark them “use client” if they absolutely require client-side features.
If a component does not use any client-side hooks or browser APIs, it should remain a Server Component. This ensures that its JavaScript is not included in the client bundle, optimizing performance.
// app/components/Card.tsx (Server Component by default)
interface CardProps {
title: string;
children: React.ReactNode;
}
export default function Card({ title, children }: CardProps) {
return (
<div style={{ border: '1px solid #eee', padding: '1rem', borderRadius
<h3>{title}</h3>
{children}
</div>
);
}
// app/page.tsx (Server Component using Card)
import Card from './components/Card';
export default function HomePage() {
return (
<main>
<h1>Welcome</h1>
<Card title="About Us">
<p>This is a description of our amazing company.</p>
</Card>
</main>
);
}
A powerful pattern is to pass Client Components as children to Server Components. This allows the Server Component to render its static parts and then
In traditional web applications, especially those relying heavily on server-side rendering (SSR) or static site generation (SSG) without advanced techniques, a common performance bottleneck arises from slow data fetching. When a page request comes in, the server often needs to fetch all the necessary data from various sources (databases, external APIs, file systems) before it can render the complete HTML for the page. This process is inherently sequential:
During steps 2, 3, and 4, the user sees a blank screen or a loading spinner, leading to a poor perceived performance. Even if the first byte of HTML arrives quickly, the user cannot interact with the page until all critical data is loaded and the page is fully rendered and hydrated. This delay, often referred to as Time To First Byte (TTFB) or First Contentful Paint (FCP), can significantly impact user experience and conversion rates.
Consider an e-commerce product page. It might need to fetch:
If each of these data fetches takes time, the user might stare at a blank page for several seconds, even if the product name and image are available much sooner than the reviews or related products. This is where streaming comes in.
Streaming is a technique that allows a web server to send parts of a web page to the client as they become ready, rather than waiting for the entire page to be fully rendered. In the context of React and Next.js, this means that the server can send HTML for certain components as soon as their data is available, while other parts of the page that are still fetching data can display a loading fallback. This significantly improves perceived performance and user experience because users see meaningful content sooner and can start interacting with parts of the page that are already loaded.
Next.js leverages React Suspense to implement streaming. Suspense is a React feature that lets components “wait” for something to load, showing a fallback until the data is ready. Next.js integrates Suspense with its server-side rendering to enable streaming HTML.
Streaming offers several key advantages:
Next.js provides a built-in mechanism to implement streaming loading states using the loading.tsx file convention, which is powered by React Suspense. When a user navigates to a route, if a loading.tsx file exists within that route segment, Next.js will immediately display
the UI defined in loading.tsx while the data for the page.tsx (and its nested Server Components) is being fetched.
This provides an instant loading indicator, preventing a blank screen and improving the perceived responsiveness of the application.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-t-2 bordi
<p className="ml-4 text-lg text-gray-600">Loading dashboard data...
</div>
);
}
// app/dashboard/page.tsx (This component will be rendered after data is ।
import { getDashboardData } from "../../lib/data";
export default async function DashboardPage() {
const data = await getDashboardData(); // Simulate slow data fetch
return (
<main className="p-4">
<h1 className="text-2xl font-bold mb-4">Your Dashboard</h1>
<p>Welcome back, {data.user.name}!</p>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="p-4 border rounded shadow">Total Sales: ${data.sa"
<div className="p-4 border rounded shadow">New Users: {data.newUs।
</div>
</main>
);
}
// lib/data.ts (Simulated slow data fetch)
export async function getDashboardData() {
return new Promise((resolve) = {
setTimeout(() = {
resolve({
user: { name: "John Doe" },
sales: 12345.67,
newUsers: 123,
});
}, 2000); // Simulate 2-second data fetch
});
}
In this example, when navigating to /dashboard, the user will immediately see the spinning loader and the “Loading dashboard data…” message. After 2 seconds, the actual dashboard content will appear.
The loading.tsx file is a specific application of React Suspense at the route segment level. You can also use components directly within your Server Components to wrap individual asynchronous components or data fetches. This allows for more granular control over loading states within a page.
// app/dashboard/page.tsx (using inline Suspense)
import { Suspense } from 'react';
import { getDashboardData, getRecentActivities } from "../../lib/data";
import RecentActivities from "../../components/RecentActivities"; // Coul।
export default async function DashboardPage() {
const data = await getDashboardData(); // This will block the entire pa!
return (
<main className="p-4">
<h1 className="text-2xl font-bold mb-4">Your Dashboard</h1>
<p>Welcome back, {data.user.name}!</p>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="p-4 border rounded shadow">Total Sales: ${data.saˋ
<div className="p-4 border rounded shadow">New Users: {data.newUs।
</div>
<h2 className="text-xl font-bold mt-8 mb-4">Recent Activities</h2>
<Suspense fallback={<div>Loading activities...</div>}> {/* Individui
<RecentActivities />
</Suspense>
</main>
);
}
// components/RecentActivities.tsx (Server Component)
import { getRecentActivities } from "../lib/data";
export default async function RecentActivities() {
const activities = await getRecentActivities(); // Simulate slow fetch ·
return (
<ul className="border rounded shadow p-4">
{activities.map((activity: any) = (
<li key={activity.id} className="mb-2">{activity.description}</li:
))}
</ul>
);
}
// lib/data.ts (Simulated slow data fetch for activities)
export async function getRecentActivities() {
return new Promise((resolve) = {
setTimeout(() = {
resolve([
{ id: 1, description: "User Alice signed up" },
{ id: 2, description: "Product X sold" },
{ id: 3, description: "User Bob updated profile" },
]);
}, 3000); // Simulate 3-second data fetch
});
}
In this setup, the DashboardPage will render its main content (user welcome, sales, new users) after getDashboardData() resolves (2 seconds). The RecentActivities component, however, will display “Loading activities…” for an additional second (total 3 seconds) before its content appears. This demonstrates how granular Suspense boundaries allow different parts of your UI to load independently.
Progressive rendering is the core concept behind Next.js streaming with React Server Components. It means that the server doesn’t wait for all data to be fetched and all components to be rendered before sending any HTML to the client. Instead, it sends HTML in chunks as soon as parts of the page are ready.
Here’s how it works:
This process ensures that the user sees content progressively, from the most basic layout to fully interactive, data-rich sections. The browser can start rendering and even hydrating parts of the page while other parts are still being processed on the server.
Each of these parts can be streamed and rendered independently, leading to a much smoother and more responsive user experience compared to waiting for the entire page to be ready before displaying anything.
[1] Vercel. (n.d.). Next.js Documentation: Loading UI and Streaming. Retrieved from https://nextjs.org/docs/app/building-your-application/routing/loading [2] React. (n.d.). React Documentation: Suspense. Retrieved from https://react.dev/reference/react/Suspense [3] Vercel. (n.d.). Next.js Documentation: Data Fetching. Retrieved from https://nextjs.org/docs/app/building-your-application/data-fetching
In Chapter 3, we introduced the concept of loading.tsx for route-level loading states and basic components for individual asynchronous operations. However, real-world applications often involve multiple data fetches and complex UI structures that load at different rates. Nested Suspense boundaries allow you to orchestrate these complex loading sequences, providing a fine-grained control over the user experience.
When you nest components, the inner Suspense boundary will display its fallback if its children are still loading, while the outer Suspense boundary will only display its fallback if all its children (including the inner Suspense and its content) are still loading. This creates a waterfall effect where content appears progressively.
Consider a page with a main content area and a sidebar, both of which fetch data independently. The main content might load quickly, while the sidebar might take longer due to a more complex query or external API call.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import MainContent from '../../components/MainContent'; // Server Compone।
import Sidebar from '../../components/Sidebar'; // Server Component
export default function DashboardPage() {
return (
<div className="flex">
<div className="flex-grow p-4">
<Suspense fallback={<div>Loading main content...</div>}> {/* Outel
<MainContent />
</Suspense>
</div>
<div className="w-1/4 p-4 border-l">
<Suspense fallback={<div>Loading sidebar...</div>}> {/* Inner Sus|
<Sidebar />
</Suspense>
</div>
</div>
);
}
// components/MainContent.tsx
import { getMainData } from '../lib/data';
export default async function MainContent() {
const data = await getMainData(); // Simulates 1-second fetch
return (
<div>
<h1 className="text-2xl font-bold">Main Dashboard</h1>
<p>Data: {data.value}</p>
</div>
);
}
// components/Sidebar.tsx
import { getSidebarData } from '../lib/data';
export default async function Sidebar() {
const data = await getSidebarData(); // Simulates 3-second fetch
return (
<div>
<h2 className="text-xl font-bold">Sidebar</h2>
<p>Info: \{data.details\}</p>
</div>
);
\}
// lib/data.ts
export async function getMainData() \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve(\{ value: 'Main Di
\}
export async function getSidebarData() \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve(\{ details: 'Sidel
\}
In this example:
and
as fallbacks.
Loading main content…
Loading sidebar…
This provides a much smoother user experience, as the user sees the main content appear quickly, even if the sidebar is still loading.
Nested Suspense is particularly powerful when dealing with components that have different loading priorities or dependencies. You can wrap critical components in outer Suspense boundaries with minimal fallbacks, and less critical components in inner Suspense boundaries with more detailed loading indicators.
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import ProductDetails from '../../components/ProductDetails'; // Server C।
import ProductReviews from '../../components/ProductReviews'; // Server Cl
import RelatedProducts from '../../components/RelatedProducts'; // Server
export default function ProductPage({ params }: { params: { id: string } }
return (
<div className="container mx-auto p-4">
<Suspense fallback={<h1>Loading Product...</h1>}> {/* Main product
<ProductDetails productId={params.id} />
</Suspense>
<hr className="my-8" />
<Suspense fallback={<div>Loading reviews...</div>}> {/* Reviews can
<ProductReviews productId={params.id} />
</Suspense>
<hr className="my-8" />
<Suspense fallback={<div>Loading related products...</div>}> {/* Reˋ
<RelatedProducts productId={params.id} />
</Suspense>
</div>
);
}
// components/ProductDetails.tsx (simulated)
import { getProduct } from '../lib/data';
export default async function ProductDetails({ productId }: { productId: !
const product = await getProduct(productId); // Fast fetch
return (
<div>
<h2 className="text-3xl font-bold">{product.name}</h2>
<p className="text-gray-700">{product.description}</p>
<p className="text-xl font-semibold">${product.price}</p>
</div>
);
}
// components/ProductReviews.tsx (simulated)
import { getProductReviews } from '../lib/data';
export default async function ProductReviews({ productId }: { productId:
const reviews = await getProductReviews(productId); // Medium fetch
return (
<div>
<h3 className="text-2xl font-bold">Customer Reviews</h3>
{reviews.length ≠0 ? (
<p>No reviews yet.</p>
) :(
<ul>
{reviews.map(review = (
<li key={review.id} className="mb-2 border-b pb-2">
<p><strong>{review.author}</strong>: {review.comment}</p>
</li>
))}
</ul>
)}
</div>
);
}
// components/RelatedProducts.tsx (simulated)
import { getRelatedProducts } from '../lib/data';
export default async function RelatedProducts({ productId }: { productId:
const related = await getRelatedProducts(productId); // Slow fetch
return (
<div>
<h3 className="text-2xl font-bold">Related Products</h3>
{related.length ≠ 0 ? (
<p>No related products found.</p>
) : (
<div className="grid grid-cols-3 gap-4">
{related.map(product ⇒ (
<div key={product.id} className="border p-4 rounded">
<p className="font-semibold">{product.name}</p>
<p>${product.price}</p>
</div>
))}
</div>
)}
</div>
);
\}
// lib/data.ts (simulated data fetching functions)
export async function getProduct(id: string) \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve(\{ id, name: ˋProı
\}
export async function getProductReviews(productId: string) \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve([
\{ id: 1, author: 'User A', comment: 'Great product!' \},
\{ id: 2, author: 'User B', comment: 'Highly recommend.' \},
]), 2000));
\}
export async function getRelatedProducts(productId: string) \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve([
\{ id: 'rel1', name: 'Related Product 1', price: 25.00 \},
\{ id: 'rel2', name: 'Related Product 2', price: 35.00 \},
]), 3500));
\}
This structure ensures that the most important content (ProductDetails ) appears first, followed by ProductReviews, and finally RelatedProducts, each with its own loading state, providing a highly responsive user experience.
Just as loading.tsx handles pending states, error.tsx provides a mechanism to gracefully handle errors that occur during rendering or data fetching within a route segment. When an error is thrown inside a Server Component or Client Component within a route segment, Next.js will display the UI defined in error.tsx for that segment, preventing the entire application from crashing.
// app/dashboard/error.tsx
'use client'; // Error boundaries must be Client Components
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error & { digest
useEffect(() = {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center h-screen bg
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
<p className="mb-4">{error.message}</p>
<button
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-7(
onClick={() = reset()} // Attempt to re-render the segment
>
Try again
</button>
</div>
);
}
// app/dashboard/page.tsx (Simulating an error)
import { getDashboardDataWithError } from "../../lib/data";
export default async function DashboardPage() {
const data = await getDashboardDataWithError(); // This function will tl
return (
<main className="p-4">
<h1 className="text-2xl font-bold mb-4">Your Dashboard</h1>
<p>Welcome back, {data.user.name}!</p>
</main>
);
}
// lib/data.ts (Simulated data fetch that throws an error)
export async function getDashboardDataWithError() {
return new Promise((resolve, reject) ⇒ \{
setTimeout(() ⇒ \{
reject(new Error('Failed to load critical dashboard data!')); // Si
\}, 1500);
\}) ;
\}
When navigating to /dashboard, after 1.5 seconds, the error.tsx component will be displayed, showing the error message and a “Try again” button. Clicking “Try again” will reattempt to render the DashboardPage .
Streaming allows for more flexible data fetching strategies, moving beyond the traditional all-ornothing approach. With React Server Components and Suspense, you can implement parallel and sequential data fetching patterns that optimize for user experience.
This is the most common and often most efficient strategy. When multiple data fetches are independent of each other, you can initiate them all at the same time. React Suspense will then display fallbacks for each component that is waiting for its data, and as each fetch resolves, its corresponding content will appear.
How to implement: Simply call all your async data fetching functions at the top level of your Server Component (or within separate async components wrapped in Suspense).
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { getSalesData, getAnalyticsData } from '../../lib/data';
import SalesChart from '../../components/SalesChart';
import AnalyticsSummary from '../../components/AnalyticsSummary';
export default function DashboardPage() {
// Both data fetches start in parallel
const salesPromise = getSalesData();
const analyticsPromise = getAnalyticsData();
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Dashboard Overview</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Suspense fallback={<div>Loading Sales Chart...</div>}>
<SalesChart salesPromise={salesPromise} />
</Suspense>
<Suspense fallback={<div>Loading Analytics Summary...</div>}>
<AnalyticsSummary analyticsPromise={analyticsPromise} />
</Suspense>
</div>
</div>
);
}
// components/SalesChart.tsx (Server Component)
export default async function SalesChart({ salesPromise }: { salesPromise
const salesData = await salesPromise; // Await the promise passed from |
return (
<div className="border p-4 rounded shadow">
<h2 className="text-xl font-semibold">Sales Performance</h2>
<p>Total Revenue: ${salesData.totalRevenue}</p>
{/* Render chart here with salesData */}
</div>
);
}
// components/AnalyticsSummary.tsx (Server Component)
export default async function AnalyticsSummary({ analyticsPromise }: { ani
const analyticsData = await analyticsPromise; // Await the promise passi
return (
<div className="border p-4 rounded shadow">
<h2 className="text-xl font-semibold">User Analytics</h2>
<p>New Users: \{analyticsData.newUsers\}</p>
<p>Page Views: \{analyticsData.pageViews\}</p>
\{/* Render summary here with analyticsData */\}
</div>
);
\}
// lib/data.ts (Simulated data fetches)
export async function getSalesData() \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve(\{ totalRevenue:
\}
export async function getAnalyticsData() \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve(\{ newUsers: 500,
\}
In this example, getSalesData and getAnalyticsData are initiated in parallel.
AnalyticsSummary will resolve and display its content after 1 second, while SalesChart will display its loading fallback for 2 seconds before its content appears. This ensures that the faster data is shown to the user as soon as possible.
Sometimes, one data fetch depends on the result of another. In such cases, you need to perform sequential data fetching. While this can introduce latency, streaming still helps by allowing the UI to render progressively up to the point where the dependency is met.
How to implement: Simply await the first data fetch before initiating the second one.
// app/user-dashboard/[userId]/page.tsx
import { Suspense } from 'react';
import { getUserProfile, getUserOrders } from '../../lib/data';
import UserProfile from '../../components/UserProfile';
import UserOrders from '../../components/UserOrders';
export default async function UserDashboardPage({ params }: { params: { u:
// Fetch user profile first
const userProfile = await getUserProfile(params.userId);
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">User Dashboard for {userPro-
<UserProfile profile={userProfile} />
<Suspense fallback={<div>Loading user orders...</div>}>
{/* Fetch orders only after userProfile is available */}
<UserOrders userId={params.userId} />
</Suspense>
</div>
);
}
// components/UserProfile.tsx (Server Component)
export default function UserProfile({ profile }: { profile: any }) {
return (
<div className="border p-4 rounded shadow mb-4">
<h2 className="text-xl font-semibold">Profile</h2>
<p>Name: {profile.name}</p>
<p>Email: {profile.email}</p>
</div>
);
}
// components/UserOrders.tsx (Server Component)
import { getUserOrders } from '../../lib/data';
export default async function UserOrders({ userId }: { userId: string }) •
const orders = await getUserOrders(userId); // This fetch depends on us।
return (
<div className="border p-4 rounded shadow">
<h2 className="text-xl font-semibold">Recent Orders</h2>
\{orders.length \(\equiv 0\) ? (
<p>No orders found.</p>
) : (
<ul>
\{orders.map((order: any) ⇒ (
<li key=\{order.id\}>Order \#\{order.id\}: \$\{order.amount\}</li>
))\}
</ul>
)\}
</div>
);
\}
// lib/data.ts (Simulated data fetches)
export async function getUserProfile(userId: string) \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve(\{ id: userId, nar
\}
export async function getUserOrders(userId: string) \{
return new Promise(resolve ⇒ setTimeout(() ⇒ resolve([
\{ id: 'ORD001', amount: 120.50 \},
\{ id: 'ORD002', amount: 55.00 \},
]), 2500)); // This fetch starts after getUserProfile resolves
\}
In this sequential example, the UserProfile component will render after 1.5 seconds. Then, the UserOrders component will start fetching its data and display “Loading user orders…” for another 2.5 seconds before its content appears. The user sees the profile information first, which is often more critical, while waiting for the less critical order data.
While streaming significantly enhances user experience and perceived performance, it’s important to consider its implications for Search Engine Optimization (SEO) and accessibility.
<div aria-live="polite" aria-busy="true">
Loading content...
</div>
By carefully considering these SEO and accessibility best practices, you can ensure that your streaming Next.js applications not only provide a fast and dynamic user experience but are also discoverable by search engines and usable by everyone.
[1] Vercel. (n.d.). Next.js Documentation: Loading UI and Streaming. Retrieved from https://nextjs.org/docs/app/building-your-application/routing/loading [2] React. (n.d.). React Documentation: Suspense. Retrieved from https://react.dev/reference/react/Suspense [3] Vercel. (n.d.). Next.js Documentation: Data Fetching. Retrieved from https://nextjs.org/docs/app/building-your-application/data-fetching [4] MDN Web Docs. (n.d.). ARIA live regions. Retrieved from https://developer.mozilla.org/enUS/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
Server Actions are a powerful new feature introduced in Next.js with the App Router, built on top of React Server Components. They allow you to define asynchronous functions that run directly on the server, enabling server-side data mutations and form submissions without the need to create separate API routes. This significantly simplifies full-stack development by bringing server-side logic closer to the components that use it.
Prior to Server Actions, performing data mutations (like creating, updating, or deleting data) from a client-side form typically involved:
Server Actions streamline this process by allowing you to define the mutation logic directly within your React components or in separate files that are then imported into your components. This
leads to:
To define a Server Action, you mark an asynchronous function with the “use server” directive. This directive can be placed at the top of a file to mark all exported functions in that file as Server Actions, or directly inside the function body to mark only that specific function as a Server Action.
When “use server” is at the top of a file, all exported async functions in that file are considered Server Actions.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Simulate database insertion
console.log("Creating post:", { title, content });
await new Promise((resolve) ⇒ setTimeout(resolve, 1000)); // Simulate |
// In a real app, you would save to a database here
// const newPost = await db.posts.create({ data: { title, content } });
revalidatePath("/blog"); // Revalidate the blog page to show the new po:
redirect("/blog"); // Redirect to the blog page
}
export async function deletePost(postId: string) {
// Simulate database deletion
console.log("Deleting post:", postId);
await new Promise((resolve) ⇒ setTimeout(resolve, 500));
revalidatePath("/blog");
}
When “use server” is inside an async function, only that specific function is a Server Action. This is useful when you want to define a Server Action directly within a Server Component.
// app/components/PostForm.tsx (Server Component)
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export default function PostForm() {
async function createPost(formData: FormData) {
"use server"; // Marks this specific function as a Server Action
const title = formData.get("title") as string;
const content = formData.get("content") as string;
console.log("Creating post from inline action:", { title, content });
await new Promise((resolve) ⇒ setTimeout(resolve, 1000));
revalidatePath("/blog");
redirect("/blog");
}
return (
<form action={createPost}>
<input type="text" name="title" placeholder="Title" />
<textarea name="content" placeholder="Content"></textarea>
<button type="submit">Create Post</button>
</form>
);
}
Server Actions can be invoked in several ways:
The most elegant way to handle forms with Server Actions is by leveraging the native HTML
element’s action prop. When you pass a Server Action to the action prop, Next.js automatically handles the form submission, serialization of FormData, and invocation of the Server Action on the server.
Basic Form Submission
// app/new-post/page.tsx import { createPost } from "../actions"; // Assuming actions.ts from abovi export default function NewPostPage() { return ( <main className="p-4"> <h1 className="text-2xl font-bold mb-4">Create New Post</h1> <form action={createPost} className="flex flex-col gap-4 max-w-md"> <input type="text" name="title" placeholder="Post Title" required className="p-2 border rounded" /> <textarea name="content" rows={8} placeholder="Post Content" required className="p-2 border rounded" ></textarea> <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-bl। > Submit Post </button> </form> </main> ); }
When the user submits this form, the createPost Server Action will be executed on the server. The FormData object automatically contains the values from the form fields (title and content).
Adding Pending States with useFormStatus
For a better user experience, you often want to show a pending state (e.g., disabling the submit button) while the Server Action is executing. Next.js provides the useFormStatus hook (a Client Component hook) for this purpose.
// app/components/SubmitButton.tsx "use client"; import { useFormStatus } from "react-dom"; // From react-dom export function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending} className={ˋpx-4 py-2 rounded ${pending ? "bg-gray-400" : "bg-blue-( > {pending ? "Submitting..." : "Submit Post"} </button> ); } // app/new-post/page.tsx (updated) import { createPost } from "../actions"; import { SubmitButton } from "../components/SubmitButton"; export default function NewPostPage() { return ( <main className="p-4"> <h1 className="text-2xl font-bold mb-4">Create New Post</h1> <form action={createPost} className="flex flex-col gap-4 max-w-md"> <input type="text" name="title" placeholder="Post Title" required className="p-2 border rounded" /> <textarea name="content" rows={8} placeholder="Post Content" required className="p-2 border rounded" ></textarea> <SubmitButton /> {/* Use the Client Component button */}
`</form>
</main>
);
}`
Programmatic Invocation with useTransition
If you need to invoke a Server Action programmatically (e.g., after some client-side validation, or from a non-form element), you can use the useTransition hook from React.
// app/components/DeleteButton.tsx "use client"; import { useTransition } from "react"; import { deletePost } from "../actions"; // Assuming actions.ts export function DeleteButton({ postId }: { postId: string }) { const [isPending, startTransition] = useTransition(); const handleDelete = () = { startTransition(async () = { await deletePost(postId); // Optionally, handle UI updates or messages after deletion }); }; return ( <button onClick={handleDelete} disabled={isPending} className={ˋpx-3 py-1 rounded text-sm ${isPending ? "bg-gray-400" : > {isPending ? "Deleting..." : "Delete"} </button> ); }
Revalidation and Redirects: Updating UI After Mutations
After a Server Action successfully modifies data, you often need to update the UI to reflect these changes. Next.js provides two key functions for this:
• revalidatePath(path: string) : Invalidates the cache for a specific data path, triggering a re-render of the affected components on the next request. This is crucial for ensuring that users see the most up-to-date data after a mutation.
• redirect(url: string) : Redirects the user to a different URL. This is commonly used after a successful form submission (e.g., redirecting from a “Create Post” page to the newly created post’s page or a list of posts).
These functions are typically called at the end of a Server Action.
// app/actions.ts "use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; export async function updateProfile(formData: FormData) { const userId = formData.get("userId") as string; const newName = formData.get("name") as string; // Simulate database update console.log(ˋUpdating profile for user ${userId} to name: ${newName}ˋ); await new Promise((resolve) = setTimeout(resolve, 800)); // 1. Revalidate the profile page to show the new name revalidatePath(ˋ/profile/${userId}ˋ); // 2. Redirect the user back to their profile page redirect(ˋ/profile/${userId}ˋ); }
Error Handling in Server Actions
Handling errors gracefully is essential for a robust application. When a Server Action encounters an error (e.g., validation failure, database error), you need a way to communicate that error back to the client so the user can take corrective action.
Returning Error Objects
The recommended approach is to return an object from the Server Action that contains either the successful result or an error message.
// app/actions.ts "use server"; export async function subscribeToNewsletter(formData: FormData) { const email = formData.get("email") as string; // Basic validation if (!email || !email.includes("@")) { return { error: "Please provide a valid email address." }; } try { // Simulate database insertion or API call console.log(ˋSubscribing ${email} to newsletter...ˋ); await new Promise((resolve, reject) = { setTimeout(() = { // Simulate a random failure for demonstration if (Math.random() > 0.5) { reject(new Error("Database connection failed.")); } else { resolve(true); } }, 1000); }); return { success: "Successfully subscribed!" }; } catch (error: any) { console.error("Subscription error:", error); return { error: "An unexpected error occurred. Please try again later } }
Handling Errors on the Client
To handle the returned error object on the client, you can use the useFormState hook (from react-dom ). This hook allows you to manage the state of a form based on the result of a Server Action.
// app/components/NewsletterForm.tsx "use client"; import { useFormState } from "react-dom"; import { subscribeToNewsletter } from "../actions"; import { SubmitButton } from "./SubmitButton"; // Reusing the button from // Define the initial state for the form const initialState = { error: null, success: null, }; export default function NewsletterForm() { // useFormState takes the Server Action and the initial state const [state, formAction] = useFormState(subscribeToNewsletter, initial! return ( <form action={formAction} className="flex flex-col gap-2 max-w-sm"> <label htmlFor="email" className="font-semibold">Subscribe to our nı <input type="email" id="email" name="email" placeholder="Enter your email" required className="p-2 border rounded" /> <SubmitButton /> {/* Display error or success messages based on the form state */} {state?.error && <p className="text-red-500 text-sm">{state.error}<, {state?.success && <p className="text-green-600 text-sm">{state.suc| </form> ); }
In this setup, when the form is submitted, useFormState calls the subscribeToNewsletter Server Action. When the action completes, useFormState updates the state variable with the returned object (either $true$ error: … $true$ or $true$ success: … } ), which is then used to conditionally render messages in the UI.
By mastering Server Actions, you can significantly simplify data mutations in your Next.js applications, creating more secure, performant, and maintainable full-stack code.
References
[1] Vercel. (n.d.). Next.js Documentation: Server Actions and Mutations. Retrieved from https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations [2] React. (n.d.). React Documentation: useFormStatus . Retrieved from https://react.dev/reference/react-dom/hooks/useFormStatus [3] React. (n.d.). React Documentation: useFormState . Retrieved from https://react.dev/reference/reactdom/hooks/useFormState
Chapter 6: Route Handlers for API Endpoints
When to Use Route Handlers vs. Server Actions: Key Distinctions
With the introduction of Server Actions, Next.js now offers two primary ways to handle serverside logic: Server Actions (discussed in Chapter 5) and Route Handlers. While both execute on the server, they serve different purposes and are best suited for different scenarios. Understanding their distinctions is crucial for choosing the right tool for your specific needs.FeatureServer ActionsRoute HandlersPrimary Use CaseData mutations (forms, database updates) directly from components.Building traditional RESTful APIs, webhooks, or third-party integrations.InvocationDirectly from form elements or startTransition in Client Components.Via standard HTTP requests ( fetch , Axios) from any client (browser, mobile app, other servers).InputPrimarily FormData object (for forms) or serializable arguments.Request object (access to headers, body, query params).OutputReturn values directly to the component that invoked them.Response object (JSON, text, file, etc.) with HTTP status codes.LocationCan be defined inline in Server Components or in separate files ( “use server”).Must be defined in route.ts (or .js ) files within the app/api directory.AuthenticationOften implicitly handled by Next.js session management or direct database access.Requires explicit authentication/authorization logic (e.g., JWT, API keys).Caching/RevalidationIntegrated with revalidatePath, revalidateTag.Standard HTTP caching headers ( Cache-Control ).StreamingCan stream UI updates (e.g., useFormStatus ).Can stream data responses (e.g., Response.json(data, { status: 200 })).
In summary:
• Use Server Actions when you need to perform data mutations directly tied to user interactions within your Next.js application, especially from forms, and you want to leverage
Next.js’s integrated caching and revalidation. They are ideal for tightly coupled UI and data operations.
• Use Route Handlers when you need to expose a standard HTTP API endpoint that can be consumed by any client, including external services, mobile apps, or even other Next.js applications. They are perfect for building traditional backend services within your Next.js project.
Creating RESTful APIs with Route Handlers: GET, POST , PUT, DELETE Methods
Route Handlers allow you to create custom request handlers for any HTTP method within the app/api directory. Each file named route.ts (or .js ) inside a route segment in $true$ will define the API endpoint for that segment. You export functions corresponding to HTTP methods ( GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS ) to handle requests.
Basic Structure of a Route Handler
// app/api/products/route.ts import { NextResponse } from 'next/server'; // GET /api/products export async function GET(request: Request) { // Logic to fetch all products const products = [ { id: 1, name: 'Laptop', price: 1200 }, { id: 2, name: 'Mouse', price: 25 }, ]; return NextResponse.json(products); } // POST /api/products export async function POST(request: Request) { const body = await request.json(); // Logic to create a new product const newProduct = { id: Math.random(), ...body }; // Simulate ID generi return NextResponse.json(newProduct, { status: 201 }); } // app/api/products/[id]/route.ts // GET /api/products/:id export async function GET(request: Request, { params }: { params: { id: s* const id = params.id; // Logic to fetch a single product by ID const product = { id: Number(id), name: 'Laptop', price: 1200 }; // Simı if (!product) { return new NextResponse('Product not found', { status: 404 }); } return NextResponse.json(product); } // PUT /api/products/:id export async function PUT(request: Request, { params }: { params: { id: s* const id = params.id; const body = await request.json(); // Logic to update a product by ID const updatedProduct = { id: Number(id), ...body }; // Simulate update return NextResponse.json(updatedProduct); }
// DELETE /api/products/:id export async function DELETE(request: Request, { params }: { params: { id const id = params.id; // Logic to delete a product by ID console.log(ˋDeleting product with ID: ${id}ˋ); return new NextResponse(null, { status: 204 }); }
Key Features of Route Handlers:
• NextResponse : A Next.js specific extension of the Web Response API, providing convenient methods for returning JSON, redirecting, and setting headers.
• Request Object: Provides access to the incoming HTTP request, including headers, body, query parameters, and more.
• Dynamic Segments: You can define dynamic segments in your API routes (e.g., [id] ) to capture parameters from the URL, similar to dynamic pages.
• Edge Runtime Compatibility: Route Handlers are designed to run on the Edge Runtime by default, offering low latency and high scalability.
Authentication and Authorization in Route Handlers
Implementing authentication and authorization is critical for securing your API endpoints. Since Route Handlers are essentially backend endpoints, you need to explicitly handle these concerns.
Authentication Strategies
// app/api/protected-data/route.ts import { getServerSession } from 'next-auth'; import { authOptions } from '../../api/auth/[...nextauth]/route'; // Y import { NextResponse } from 'next/server'; export async function GET(request: Request) { const session = await getServerSession(authOptions); if (!session) { return new NextResponse('Unauthorized', { status: 401 }); } // User is authenticated, proceed with logic return NextResponse.json({ data: 'This is protected data', user: ses }
// app/api/external-service/route.ts import { NextResponse } from 'next/server'; export async function POST(request: Request) { const apiKey = request.headers.get('X-API-Key'); const expectedApiKey = process.env.EXTERNAL_SERVICE_API_KEY; // Stor if (!apiKey || apiKey \equiv expectedApiKey) { return new NextResponse('Unauthorized: Invalid API Key', { status: } const body = await request.json(); // Process data from external service return NextResponse.json({ message: 'Data received successfully' }); }
// app/api/user-settings/route.ts import { NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; // You'd use a library like 'jsonwebto export async function GET(request: Request) { const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return new NextResponse('Unauthorized', { status: 401 }); } const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, process.env.JWT_SECRET!); // Ver // Access user ID from decoded token: decoded.userId return NextResponse.json({ settings: 'User specific settings' }); } catch (error) { return new NextResponse('Forbidden: Invalid token', { status: 403 } }
Authorization (Role-Based Access Control)
After authenticating a user, you often need to check if they have the necessary permissions to perform a specific action. This is authorization.
// app/api/admin/users/route.ts import { getServerSession } from 'next-auth'; import { authOptions } from '../../api/auth/[...nextauth]/route'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { const session = await getServerSession(authOptions); if (!session) { return new NextResponse('Unauthorized', { status: 401 }); } // Assuming user roles are part of the session object if (session.user?.role # 'admin') { return new NextResponse('Forbidden: Insufficient permissions', { stat। } // Only admins can access this data return NextResponse.json({ users: ['Alice', 'Bob', 'Charlie'] }); }
Integrating with Databases and External Services
Route Handlers are the ideal place to interact with your database or call external third-party services, as they run in a secure server-side environment.
Database Integration
You can import and use your database client (e.g., Prisma, Drizzle ORM, raw SQL client) directly within your Route Handlers.
// lib/db.ts (Example Prisma client setup) import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export default prisma; // app/api/posts/route.ts import { NextResponse } from 'next/server'; import prisma from '../../../lib/db'; export async function GET() { const posts = await prisma.post.findMany(); return NextResponse.json(posts); } export async function POST(request: Request) { const { title, content } = await request.json(); const newPost = await prisma.post.create({ data: { title, content } }); return NextResponse.json(newPost, { status: 201 }); }
Calling External Services
Similarly, you can make HTTP requests to external APIs from your Route Handlers.
// app/api/weather/route.ts import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const city = searchParams.get('city'); if (!city) { return new NextResponse('City parameter is required', { status: 400 }. } try { const apiKey = process.env.OPENWEATHER_API_KEY; // Securely stored AP: const weatherResponse = await fetch( ˋhttps://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${i ); const weatherData = await weatherResponse.json(); if (!weatherResponse.ok) { throw new Error(weatherData.message || 'Failed to fetch weather'); } return NextResponse.json({ city: weatherData.name, temperature: weatherData.main.temp, conditions: weatherData.weather[0].description, }); } catch (error: any) { console.error('Error fetching weather:', error); return new NextResponse(ˋError: ${error.message}ˋ, { status: 500 }); } }
CORS and Security Considerations
When building APIs with Route Handlers, it’s important to consider Cross-Origin Resource Sharing (CORS) and other security best practices.
CORS (Cross-Origin Resource Sharing)
By default, browsers enforce the Same-Origin Policy, which prevents web pages from making requests to a different domain than the one that served the web page. CORS is a mechanism that allows servers to specify who (which origins) can access their resources.
If your Route Handlers are consumed by a client on a different domain (e.g., a separate frontend application, a mobile app, or a third-party service), you might need to configure CORS headers.
// app/api/data/route.ts import { NextResponse } from 'next/server'; export async function GET() { const response = NextResponse.json({ message: 'Hello from API' }); // Allow requests from a specific origin response.headers.set('Access-Control-Allow-Origin', '<https://your-front>। // Allow specific methods response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DI // Allow specific headers response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Autl // Allow credentials (e.g., cookies, HTTP authentication) to be sent wi- response.headers.set('Access-Control-Allow-Credentials', 'true'); return response; } // Handle preflight OPTIONS requests export async function OPTIONS(request: Request) { const response = new NextResponse(null, { status: 204 }); response.headers.set('Access-Control-Allow-Origin', '<https://your-front>। response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DI response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Autl response.headers.set('Access-Control-Allow-Credentials', 'true'); return response; }
Important: Be cautious with Access-Control-Allow-Origin: * as it allows any domain to access your API, which might be a security risk depending on your API’s purpose.
Other Security Best Practices
• Input Validation: Always validate and sanitize all incoming data (query parameters, request body, headers) to prevent injection attacks (SQL injection, XSS) and ensure data integrity.
• Rate Limiting: Implement rate limiting to prevent abuse and denial-of-service attacks. You can use middleware or external services for this.
• Environment Variables: Store all sensitive information (API keys, database credentials, secrets) in environment variables and never hardcode them in your code.
• Error Handling: Avoid exposing sensitive error details (e.g., stack traces, database error messages) in your API responses. Log detailed errors internally and return generic error messages to the client.
• HTTPS: Always use HTTPS to encrypt communication between clients and your API endpoints.
• Dependency Security: Regularly update your dependencies to patch known vulnerabilities.
By following these guidelines, you can build secure, robust, and scalable API endpoints using Next.js Route Handlers.
References
[1] Vercel. (n.d.). Next.js Documentation: Route Handlers. Retrieved from https://nextjs.org/docs/app/building-your-application/routing/route-handlers [2] NextAuth.js. (n.d.). NextAuth.js Documentation. Retrieved from https://next-auth.js.org/ [3] MDN Web Docs. (n.d.). Cross-Origin Resource Sharing (CORS). Retrieved from https://developer.mozilla.org/enUS/docs/Web/HTTP/CORS
Chapter 7: Introduction to the Vercel AI SDK
What is the Vercel AI SDK?
The Vercel AI SDK is an open-source library designed to help developers build AI-powered applications with ease, particularly within the Next.js ecosystem. It provides a set of tools and utilities that streamline the integration of large language models (LLMs) and other AI services into web applications. Developed by Vercel, the creators of Next.js, the AI SDK is optimized for
modern web architectures, including React Server Components and streaming, making it a powerful choice for building performant and engaging AI experiences.
Key Features of the Vercel AI SDK:
• Framework Agnostic (but Next.js Optimized): While it works well with any React framework, it offers first-class support and optimizations for Next.js, especially with the App Router and React Server Components.
• Streaming UI Support: A core feature is its ability to handle streaming responses from LLMs. This means that instead of waiting for an entire AI-generated response to be complete, the UI can update in real-time as tokens are generated, significantly improving perceived performance and user experience.
• Hooks for Client-Side Interactivity: Provides React Hooks (e.g., useChat , useCompletion ) that simplify the integration of AI features into Client Components, managing state, input, and streaming output.
• Server-Side Utilities: Offers server-side functions and helpers that facilitate interaction with LLMs from Server Components or API routes, ensuring that sensitive API keys remain on the server.
• Provider Agnostic: Supports various LLM providers out-of-the-box, including OpenAI, Anthropic, Google Gemini, and Hugging Face, allowing developers flexibility in choosing their preferred model [1].
• Function Calling Integration: Simplifies the process of enabling LLMs to call external tools or APIs, expanding the capabilities of AI agents.
• Type Safety: Built with TypeScript, providing excellent type inference and safety for a more robust development experience.
Setting Up the AI SDK: Installation, API Key Configuration
Getting started with the Vercel AI SDK is straightforward. This section will guide you through the installation process and how to configure your API keys.
Installation
The Vercel AI SDK can be installed using npm, yarn, or pnpm. It’s recommended to install the core package along with the specific provider package you intend to use (e.g., @aisdk/openai for OpenAI models).
`npm install ai @ai-sdk/openai
yarn add ai @ai-sdk/openai
pnpm add ai @ai-sdk/openai`
API Key Configuration
To interact with LLM providers, you’ll need an API key. These keys are sensitive and should never be exposed on the client-side. The Vercel AI SDK is designed to work seamlessly with environment variables, ensuring your keys remain secure on the server.
OPENAI_API_KEY=sk-your-openai-api-key-here ANTHROPIC_API_KEY=sk-ant-api03-your-anthropic-api-key-here GOOGLE_API_KEY=your-google-api-key-here
Note: The specific environment variable name might vary depending on the provider. Refer to the AI SDK documentation for the exact variable names for each provider [2].
3. Accessing Keys: Next.js automatically loads environment variables from .env. local . These variables are accessible on the server-side. For client-side access (which is generally discouraged for sensitive keys), you would need to prefix them with NEXT_PUBLIC_ (e.g., NEXT_PUBLIC_OPENAI_API_KEY ), but for AI SDK interactions, it’s best to keep them server-side.
Basic AI Chatbot Implementation: Using UseChat Hook for Client-Side Chat
One of the most common use cases for LLMs is building interactive chatbots. The Vercel AI SDK provides the useChat hook, which simplifies the process of creating a client-side chat interface with streaming capabilities.
useChat Hook Overview
The useChat hook provides everything you need to manage a chat conversation:
• messages : An array of message objects, representing the conversation history.
• input : The current value of the user’s input field.
• handleInputChange : An event handler for updating the input field.
• handleSubmit : An event handler for submitting the user’s message.
• isLoading : A boolean indicating if an AI response is currently being generated.
• error : An error object if something went wrong during the AI interaction.
Example: Building a Simple Chatbot
Let’s create a basic chatbot component that uses the useChat hook to interact with an OpenAl model.
// app/api/chat/route.ts import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; export async function POST(req: Request) { const { messages } = await req.json(); const result = await streamText({ model: openai("gpt-4o-mini"), // Specify the model to use messages, }); return result.toAIStreamResponse(); }
Explanation: This Route Handler receives messages from the client, uses the openai model to generate a streaming text response, and then converts that stream into a format compatible with the AI SDK client. [3]
2. Create the Client-Side Chat Component:
// app/components/Chat.tsx 'use client'; import { useChat } from 'ai/react'; export default function Chat() { const { messages, input, handleInputChange, handleSubmit, isLoading return ( <div className="flex flex-col w-full max-w-md py-24 mx-auto stretc {messages.map(m = ( <div key={m.id} className="whitespace-pre-wrap"> <strong>{m.role \Longleftarrow = 'user' ? 'User: ' : 'AI: '}</strong> {m.content} </div> ))} <form onSubmit={handleSubmit} className="fixed bottom-0 w-full m <input className="flex-grow p-2 border border-gray-300 rounded shad value={input} placeholder="Say something..." onChange={handleInputChange} disabled={isLoading} /> <button type="submit" className="ml-2 px-4 py-2 bg-blue-500 text-white rounded sha disabled={isLoading} > Send </button> </form> </div> ); }
// app/page.tsx import Chat from './components/Chat'; export default function HomePage() { return ( <main className="flex min-h-screen flex-col items-center justify-b <h1 className="text-4xl font-bold mb-8">AI Chatbot</h1> <Chat /> </main> ); }
Now, when you run your Next.js application, you’ll have a functional chatbot where you can type messages, and the AI will respond in a streaming fashion.
Streaming AI Responses: How the SDK Handles Real-Time Output from LLMs
One of the most compelling aspects of the Vercel AI SDK is its robust support for streaming AI responses. Traditional API calls often wait for the entire response to be generated before sending it back. For LLMs, which can generate lengthy texts, this can lead to significant delays and a poor user experience. Streaming, however, allows the LLM to send back tokens (words or sub-words) as they are generated, and the AI SDK efficiently handles this real-time output.
The Streaming Process:
"ˋtypescript // app/actions/ai.ts 'use server'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function summarizeArticle(formData: FormData) { const articleContent = formData.get('articleContent') as string; if (!articleContent) {
return { error: 'Article content is required.' };
}
try {
const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: ˋSummarize the following article concisely:
${articleContent}
Summary:ˋ, // Crafting a clear prompt is crucial
`});
return { summary: text };
} catch (error) {
console.error('Error summarizing article:', error);
return { error: 'Failed to summarize article. Please try again.' };
}
}...
*Explanation: The ˋsummarizeArticleˋ function is marked ˋ'use server'ˋ, mi`
// app/components/ArticleSummarizer.tsx 'use client'; import { useState } from 'react'; import { summarizeArticle } from '../actions/ai'; export default function ArticleSummarizer() { const [summary, setSummary] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) event.preventDefault(); setLoading(true); setError(null); setSummary(null); const formData = new FormData(event.currentTarget); const result = await summarizeArticle(formData); if (result.error) { setError(result.error); } else if (result.summary) { setSummary(result.summary); } setLoading(false); }; return ( <div className="p-4 border rounded shadow"> <h2 className="text-xl font-bold mb-4">Article Summarizer</h2> <form onSubmit={handleSubmit} className="flex flex-col gap-4"> <textarea name="articleContent" rows={10} placeholder="Paste your article content here..." className="w-full p-2 border rounded" disabled={loading} /> <button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:b disabled={loading}
`>
{loading ? 'Summarizing...' : 'Summarize Article'}
</button>
</form>
{error && <p className="text-red-500 mt-4">Error: {error}</p>}
{summary && (
<div className="mt-4 p-4 bg-gray-50 rounded">
<h3 className="font-semibold">Summary:</h3>
<p className="whitespace-pre-wrap">{summary}</p>
</div>
)}
</div>
);
}`
// app/page.tsx import ArticleSummarizer from './components/ArticleSummarizer'; export default function HomePage() { return ( <main className="flex min-h-screen flex-col items-center justify-b <h1 className="text-4xl font-bold mb-8">AI-Powered Tools</h1> <ArticleSummarizer /> </main> ); }
This setup allows the user to interact with the summarizer on the client, but the actual LLM call and API key handling remain securely on the server, demonstrating a powerful full-stack AI pattern.
Function Calling: Enabling LLMs to Interact with External Tools and APIs
Function calling (also known as tool use) is a sophisticated capability of modern LLMs that allows them to intelligently decide when to call a function and respond with the function’s output.
Instead of just generating text, the LLM can generate structured data (like a JSON object) that represents a function call, which your application can then execute. This bridges the gap between the LLM’s natural language understanding and the ability to interact with external systems, databases, or APIs.
How Function Calling Works:
// lib/tools.ts export async function getCurrentWeather(location: string) { // In a real application, this would call a weather API (e.g., OpenW console.log(ˋFetching weather for ${location}...ˋ); const weatherData: { [key: string]: { temperature: number; condition 'London': { temperature: 20, conditions: 'sunny' }, 'New York': { temperature: 25, conditions: 'partly cloudy' }, 'Tokyo': { temperature: 28, conditions: 'rainy' }, }; return weatherData[location] || { temperature: 'N/A', conditions: 'u }
// app/api/chat-with-tools/route.ts import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { createStreamableUI, createStreamableValue } from 'ai/rsc'; import { z } from 'zod'; import { getCurrentWeather } from '../../../lib/tools'; // Define the tool for the LLM const weatherTool = { // [2] name: 'get_current_weather', description: 'Get the current weather for a given location.', parameters: z.object({ location: z.string().describe('The city and state, e.g. San Franci }), execute: async ({ location }: { location: string }) = { return getCurrentWeather(location); }, }; export async function POST(req: Request) { const { messages } = await req.json(); const result = await streamText({ model: openai('gpt-4o-mini'), messages, tools: [weatherTool], // Register the tool with the LLM }); return result.toAIStreamResponse(); }
Explanation: We define weatherTool with a name, description, parameters (using zod for schema validation), and an execute function that calls our actual getCurrentWeather logic. This weatherTool is then passed to streamText. [2]
3. Create a Client Component to Interact with the Tool-Enabled Chat:
// app/components/WeatherChat.tsx 'use client'; import { useChat } from 'ai/react'; export default function WeatherChat() { const { messages, input, handleInputChange, handleSubmit, isLoading api: '/api/chat-with-tools', // Point to our new API route }); return ( <div className="flex flex-col w-full max-w-md py-24 mx-auto stretc {messages.map(m = ( <div key={m.id} className="whitespace-pre-wrap"> <strong>{m.role = 'user' ? 'User: ' : 'AI: '} {m.content} </div> ))} <form onSubmit={handleSubmit} className="fixed bottom-0 w-full m <input className="flex-grow p-2 border border-gray-300 rounded shad value={input} placeholder="Ask about the weather..." onChange={handleInputChange} disabled={isLoading} /> <button type="submit" className="ml-2 px-4 py-2 bg-blue-500 text-white rounded sha disabled={isLoading} > Send </button> </form> </div> ); }
4. Integrate into a Page:
// app/page.tsx import WeatherChat from './components/WeatherChat'; export default function HomePage() { return ( <main className="flex min-h-screen flex-col items-center justify-b <h1 className="text-4xl font-bold mb-8">AI Weather Assistant</h1 <WeatherChat /> </main> ); }
Now, if you ask the bot “What’s the weather in London?”, the LLM will recognize the intent, call the get_current_weather tool, and then respond with the weather information. This demonstrates how function calling enables LLMs to perform actions beyond just generating text.
Prompt Engineering Best Practices with the AI SDK
Prompt engineering is the art and science of crafting effective inputs (prompts) for LLMs to guide their behavior and elicit desired outputs. While the Vercel AI SDK simplifies the technical integration, the quality of your AI application heavily depends on the quality of your prompts. Here are some best practices:
• Be Clear and Specific: Ambiguous prompts lead to ambiguous responses. Clearly state your intent, desired format, and any constraints.
• Bad: “Write about dogs.”
• Good: “Write a 200-word blog post about the benefits of owning a golden retriever, focusing on companionship and exercise, in a friendly and encouraging tone.”
• Provide Context: Give the LLM enough background information to understand the task. This is especially important for domain-specific tasks.
• Example: “You are a customer support agent for a tech company. A user is asking about their recent order. Their order ID is #12345. Please provide an update on its shipping status.”
• Define Role and Persona: Assigning a role to the LLM can significantly influence its tone and style. This is often done using a “system” message.
• Example (in streamText or generateText messages array):
{ role: 'system', content: 'You are a helpful assistant that specia
• Specify Output Format: If you need structured output (e.g., JSON, bullet points, a table), explicitly ask for it and provide examples if necessary.
• Example: “Extract the product name, price, and description from the following text and return it as a JSON object with keys productName , price , and description .”
• Use Delimiters: For longer inputs or multiple pieces of information, use clear delimiters (e.g., triple backticks, XML tags) to separate different parts of the prompt. This helps the LLM understand what to focus on.
• Example: “Summarize the text enclosed in triple backticks: [text here]”
• Few-Shot Learning (Examples): Providing a few examples of input-output pairs can teach the LLM the desired pattern without extensive fine-tuning.
• Example: “Translate the following English sentences to French. English: Hello. French: Bonjour. English: Goodbye. French: Au revoir. English: Thank you. French:”
• Iterate and Refine: Prompt engineering is an iterative process. Start with a simple prompt, test it, and then refine it based on the LLM’s responses.
• Temperature and Top-P: Experiment with LLM parameters like temperature (controls randomness) and top_p (controls diversity) to fine-tune the output. Lower temperatures are generally better for factual, concise responses, while higher temperatures encourage creativity.
Building Custom AI Experiences: Beyond Basic Chat, e.g., Content Generation, Summarization
The Vercel AI SDK provides the building blocks for a wide array of custom AI experiences beyond simple chatbots. By combining Server Components, Server Actions, and the SDK’s utilities, you can create sophisticated applications that leverage LLMs for various tasks.
Content Generation
LLMs excel at generating human-like text. You can build tools for:
• Blog Post Generation: Provide a topic and keywords, and the LLM generates a draft.
• Marketing Copy: Generate ad headlines, product descriptions, or social media posts.
• Email Drafts: Assist users in writing professional emails based on a few bullet points.
• Creative Writing: Generate story ideas, poems, or scripts.
Example: Generating a blog post outline
// app/actions/ai.ts (extending previous file) 'use server'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateBlogOutline(topic: string) { try { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: ˋGenerate a detailed blog post outline for the topic: "${to| }); return { outline: text }; } catch (error) { console.error('Error generating blog outline:', error); return { error: 'Failed to generate outline. Please try again.' }; } } // app/components/BlogOutlineGenerator.tsx 'use client'; import { useState } from 'react'; import { generateBlogOutline } from '../actions/ai'; export default function BlogOutlineGenerator() { const [topic, setTopic] = useState(''); const [outline, setOutline] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) = event.preventDefault(); setLoading(true); setError(null); setOutline(null); const result = await generateBlogOutline(topic); if (result.error) { setError(result.error); } else if (result.outline) { setOutline(result.outline);
`}
setLoading(false);
};
return (
<div className="p-4 border rounded shadow">
<h2 className="text-xl font-bold mb-4">Blog Outline Generator</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
value={topic}
onChange={(e) ⇒ setTopic(e.target.value)}
placeholder="Enter blog post topic (e.g., 'Benefits of Server C।
className="w-full p-2 border rounded"
disabled={loading}
/>
<button
type="submit"
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-।
disabled={loading || !topic}
>
{loading ? 'Generating...' : 'Generate Outline'}
</button>
</form>
{error && <p className="text-red-500 mt-4">Error: {error}</p>}
{outline && (
<div className="mt-4 p-4 bg-gray-50 rounded">
<h3 className="font-semibold">Generated Outline:</h3>
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __|
</div>
)}
</div>
);
}`
Data Extraction and Transformation
LLMs can be used to extract structured information from unstructured text or transform data into different formats.
• Resume Parser: Extract key information (name, experience, skills) from a resume.
• Review Analysis: Summarize sentiment or extract key themes from customer reviews.
• Data Formatting: Convert natural language descriptions into structured data (e.g., JSON).
Code Generation and Refactoring
Assist developers with code-related tasks.
• Code Snippet Generation: Generate code based on natural language descriptions.
• Code Explanation: Explain complex code snippets.
• Refactoring Suggestions: Suggest improvements or refactorings for existing code.
Personalization and Recommendation Systems
Leverage LLMs to provide personalized experiences.
• Personalized Content Feeds: Curate content based on user preferences and past interactions.
• Product Recommendations: Generate tailored product suggestions.
By understanding the capabilities of the Vercel AI SDK and applying effective prompt engineering, developers can unlock a vast array of possibilities for building intelligent and highly engaging full-stack applications with Next.js.
References
[1] Vercel. (n.d.). Vercel AI SDK Documentation: generateText. Retrieved from https://sdk.vercel.ai/docs/api-reference/generate-text [2] Vercel. (n.d.). Vercel AI SDK Documentation: Tools. Retrieved from https://sdk.vercel.ai/docs/guides/tools [3] Vercel. (n.d.). Vercel AI SDK Documentation: Prompt Engineering. Retrieved from https://sdk.vercel.ai/docs/guides/prompt-engineering
Chapter 9: Retrieval-Augmented Generation (RAG) and Data Grounding
Understanding RAG: Why it’s Important for Factual and Up-toDate AI Responses
Large Language Models (LLMs) have demonstrated remarkable capabilities in generating human-like text, answering questions, and performing various language tasks. However, they
inherently suffer from several limitations:
// Example of embedding generation (conceptual, not a full script) import { openai } from '@ai-sdk/openai'; async function embedAndStore(textChunk: string) { const embedding = await openai.embedding("text-embedding-3-small").embe। text: textChunk, }); // Store textChunk and embedding.vector in your vector database console.log("Generated embedding for chunk:", embedding.vector.slice(0, } // Call this for each chunk of your documents // embedAndStore("This is a document chunk about Next.js.");
Step 2: Implement the RAG Logic in a Server Action
This Server Action will receive a user query, perform the retrieval, and then use the LLM to generate an answer.
// app/actions/rag.ts "use server"; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; // Mock function to simulate vector database search // In a real app, this would interact with Pinecone, Weaviate, Chroma, et। async function retrieveRelevantDocuments(queryEmbedding: number[]): Promi: console.log("Simulating vector search for query embedding..."); // This would typically involve a call to your vector database client // For demonstration, return some mock relevant documents await new Promise(resolve ⇒ setTimeout(resolve, 500)); // Simulate net। return [ "Next.js Server Components allow you to render components on the servi "Streaming in Next.js improves perceived performance by sending HTML । "The Vercel AI SDK simplifies integrating large language models into | ]; } export async function answerWithRAG(userQuery: string) { if (!userQuery) { return { error: "Please provide a query." }; } try { // 1. Embed the user query const queryEmbeddingResponse = await openai.embedding("text-embedding. text: userQuery, }); const queryEmbedding = queryEmbeddingResponse.vector; // 2. Retrieve relevant documents from the vector database const relevantDocs = await retrieveRelevantDocuments(queryEmbedding); // 3. Augment the prompt with retrieved context const context = relevantDocs.join("\n\n"); const augmentedPrompt = ˋBased on the following information, answer tl Information: ${context} User Question: ${userQuery}
Answer:ˋ; // 4. Generate the answer using the LLM const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: augmentedPrompt, temperature: 0.2, // Lower temperature for more factual responses }); return { answer: text, sources: relevantDocs }; } catch (error) { console.error("Error in RAG process:", error); return { error: "Failed to retrieve an answer. Please try again." }; } }
Step 3: Create a Client Component for the Q&A Interface
// app/components/RagChat.tsx "use client"; import { useState } from 'react'; import { answerWithRAG } from '../actions/rag'; export default function RagChat() { const [query, setQuery] = useState(''); const [answer, setAnswer] = useState<string | null>(null); const [sources, setSources] = useState<string[] | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const handleSubmit = async (e: React.FormEvent) = { e.preventDefault(); setLoading(true); setAnswer(null); setSources(null); setError(null); const result = await answerWithRAG(query); if (result.error) { setError(result.error); } else if (result.answer) { setAnswer(result.answer); setSources(result.sources || []); } setLoading(false); }; return ( <div className="p-4 border rounded shadow max-w-2xl mx-auto"> <h2 className="text-xl font-bold mb-4">RAG-Powered Q&A</h2> <form onSubmit={handleSubmit} className="flex flex-col gap-4"> <input type="text" value={query} onChange={(e) = setQuery(e.target.value)} placeholder="Ask a question about Next.js..." className="w-full p-2 border rounded"
`disabled={loading}
/>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-:
disabled={loading || !query}
>
{loading ? 'Searching & Answering...' : 'Get Answer'}
</button>
</form>
{error && <p className="text-red-500 mt-4">Error: {error}</p>}
{answer && (
<div className="mt-4 p-4 bg-gray-50 rounded">
<h3 className="font-semibold">Answer:</h3>
<p className="whitespace-pre-wrap">{answer}</p>
{sources && sources.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
<h4 className="font-medium">Sources:</h4>
<ul className="list-disc list-inside">
{sources.map((source, index) = (
<li key={index}>{source}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}`
Step 4: Integrate into a Page
// app/page.tsx import RagChat from './components/RagChat'; export default function HomePage() { return ( <main className="flex min-h-screen flex-col items-center justify-betwı <h1 className="text-4xl font-bold mb-8">Next.js RAG Assistant</h1> <RagChat /> </main> ); }
This example demonstrates how RAG can be integrated into a Next.js application using Server Actions and the AI SDK to provide more accurate and grounded AI responses by leveraging external data sources.
References
[1] Pinecone. (n.d.). Pinecone Documentation. Retrieved from https://www.pinecone.io/docs/ [2] Weaviate. (n.d.). Weaviate Documentation. Retrieved from https://weaviate.io/developers/weaviate [3] Qdrant. (n.d.). Qdrant Documentation. Retrieved from https://qdrant.tech/documentation/ [4] Chroma. (n.d.). Chroma Documentation. Retrieved from https://www.trychroma.com/ [5] Vercel. (n.d.). Vercel AI SDK Documentation: Embeddings. Retrieved from https://sdk.vercel.ai/docs/api-reference/openai-embedding
Chapter 10: Deployment, Performance, and Production Patterns
Deployment to Vercel: Optimizing Next.js Applications for Production
Vercel, the company behind Next.js, provides a seamless and highly optimized platform for deploying Next.js applications. Deploying to Vercel is often the most straightforward and
recommended approach, as it offers built-in optimizations, global CDN, serverless functions, and automatic scaling tailored specifically for Next.js.
Key Benefits of Deploying to Vercel:
• Zero Configuration Deployment: Connect your Git repository (GitHub, GitLab, Bitbucket), and Vercel automatically detects your Next.js project and deploys it.
• Global Edge Network: Your application is deployed to a global network of edge servers, ensuring low latency for users worldwide.
• Serverless Functions: Next.js API Routes, Route Handlers, and Server Actions are automatically deployed as serverless functions, scaling on demand and only consuming resources when requests are made.
• Automatic Scaling: Vercel handles traffic spikes and scales your application automatically without manual intervention.
• Instant Static Deployments: Static assets and pre-rendered pages are served instantly from the CDN.
• Preview Deployments: Every push to a Git branch triggers a preview deployment, allowing teams to review changes before merging to production.
• Custom Domains & SSL: Easy configuration of custom domains with automatic SSL certificate management.
• Analytics & Monitoring: Built-in tools to monitor performance and usage.
Deployment Steps:

