Next.js Full Stack Development Guide
Welcome to the world of full‑stack development with Next.js! Whether you’re a seasoned React developer or just starting out, this guide will walk you through everything you need to build, secure, and deploy a production‑ready application—all within a single framework.
Setting Up the Development Environment
Before you write a single line of code, make sure you have Node.js (v18 or later) installed. You can verify the installation with node -v and npm -v. Next, install the latest version of pnpm or yarn if you prefer them over npm; they handle monorepos and caching more efficiently.
Once the package manager is ready, create a dedicated workspace folder. Inside that folder, run the following command to bootstrap a brand‑new Next.js project with TypeScript support:
npx create-next-app@latest my-fullstack-app --ts
The command scaffolds a ready‑to‑run application, complete with a pages directory, a next.config.js file, and a tsconfig.json. Open the project in your favorite IDE, and you’ll notice that the default src/pages/index.tsx already renders a simple homepage.
Creating a Next.js Project
Next.js follows a file‑system based routing model. Every file inside pages automatically becomes a route. For example, pages/about.tsx maps to /about. This convention eliminates the need for a separate router configuration.
Let’s add a new page that displays a list of blog posts fetched from a mock API. Create src/pages/blog.tsx with the following content:
import { GetStaticProps } from 'next';
import Link from 'next/link';
type Post = {
id: number;
title: string;
};
type BlogProps = {
posts: Post[];
};
export const getStaticProps: GetStaticProps<BlogProps> = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
const posts: Post[] = await res.json();
return { props: { posts } };
};
export default function Blog({ posts }: BlogProps) {
return (
<div>
<h1>Latest Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<Link href={`/blog/${post.id}`}>
<a>{post.title}</a>
</Link>
</li>
))}
</ul>
</div>
);
}
Notice the use of getStaticProps—a data‑fetching method that runs at build time, turning static pages into lightning‑fast HTML. If you need server‑side rendering, swap it for getServerSideProps.
Dynamic Routes for Individual Posts
To display each post on its own page, create a dynamic route file: src/pages/blog/[id].tsx. The [id] segment captures the URL parameter, which you can access via useRouter or the getStaticPaths function.
import { GetStaticPaths, GetStaticProps } from 'next';
import { useRouter } from 'next/router';
type Post = {
id: number;
title: string;
body: string;
};
type PostProps = {
post: Post;
};
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
const posts: Post[] = await res.json();
const paths = posts.map(post => ({
params: { id: post.id.toString() },
}));
return { paths, fallback: false };
};
export const getStaticProps: GetStaticProps<PostProps> = async ({ params }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${params?.id}`);
const post: Post = await res.json();
return { props: { post } };
};
export default function PostPage({ post }: PostProps) {
const router = useRouter();
if (router.isFallback) {
return <p>Loading...</p>;
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
This pattern gives you static generation for each post while keeping the URL structure clean and SEO‑friendly.
API Routes – Building the Backend
One of Next.js’s most compelling features is its built‑in API routes. Any file placed under pages/api becomes an endpoint that runs on the server, giving you a full‑stack experience without a separate backend service.
Let’s create a simple CRUD API for a tasks resource. Add src/pages/api/tasks/index.ts with the following handler:
import type { NextApiRequest, NextApiResponse } from 'next';
type Task = {
id: number;
title: string;
completed: boolean;
};
let tasks: Task[] = [
{ id: 1, title: 'Learn Next.js', completed: false },
{ id: 2, title: 'Build a full‑stack app', completed: false },
];
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
if (method === 'GET') {
return res.status(200).json(tasks);
}
if (method === 'POST') {
const { title } = req.body;
const newTask: Task = {
id: Date.now(),
title,
completed: false,
};
tasks.push(newTask);
return res.status(201).json(newTask);
}
return res.status(405).end(`Method ${method} Not Allowed`);
}
The endpoint supports GET to list tasks and POST to create a new one. Because API routes run on the server, you can safely interact with databases, external services, or secret environment variables.
Consuming the API from the Frontend
Now that the backend exists, fetch data directly from a React component using the useSWR hook for caching and revalidation. Install it with npm i swr, then create src/components/TaskList.tsx:
import useSWR from 'swr';
import { useState } from 'react';
const fetcher = (url: string) => fetch(url).then(res => res.json());
export default function TaskList() {
const { data: tasks, error, mutate } = useSWR<Task[]>('/api/tasks', fetcher);
const [newTitle, setNewTitle] = useState('');
const addTask = async () => {
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle }),
});
setNewTitle('');
mutate(); // revalidate the task list
};
if (error) return <p>Failed to load tasks.</p>;
if (!tasks) return <p>Loading...</p>;
return (
<div>
<ul>
{tasks.map(task => (
<li key={task.id}>
<input type="checkbox" checked={task.completed} readOnly /> {task.title}
</li>
))}
</ul>
<input
type="text"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="New task..."
/>
<button onClick={addTask}>Add</button>
</div>
);
}
This component demonstrates real‑time UI updates without manual state management—thanks to SWR’s smart caching.
Pro tip: For production APIs, always validate request bodies with a schema library likezodoryup. It prevents malformed data from corrupting your in‑memory store or database.
Database Integration with Prisma
In‑memory arrays are great for demos, but a real application needs persistent storage. Prisma is a type‑safe ORM that works seamlessly with Next.js API routes. First, install the required packages:
npm i prisma @prisma/client
npx prisma init
The prisma init command creates a prisma/schema.prisma file. Define a simple Task model:
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
model Task {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
}
Run npx prisma migrate dev --name init to generate the SQLite database and migration files. Now you can replace the in‑memory API with a real database connection.
Update pages/api/tasks/index.ts to use Prisma:
import type { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
if (method === 'GET') {
const tasks = await prisma.task.findMany();
return res.status(200).json(tasks);
}
if (method === 'POST') {
const { title } = req.body;
const newTask = await prisma.task.create({
data: { title },
});
return res.status(201).json(newTask);
}
return res.status(405).end(`Method ${method} Not Allowed`);
}
With Prisma, you get auto‑generated TypeScript types, powerful query capabilities, and a migration system that scales from SQLite to PostgreSQL or MySQL with a single configuration change.
Pro tip: When deploying to Vercel, store your database URL in the DATABASE_URL environment variable. Prisma automatically reads it, keeping your credentials out of the codebase.
Authentication with NextAuth
Secure applications require robust authentication. NextAuth.js provides a flexible, plug‑and‑play solution that works out of the box with OAuth providers, email/password, and even custom credentials.
Install the core package and a provider of your choice (e.g., GitHub):
npm i next-auth @next-auth/github-adapter
Create a file at src/pages/api/auth/[...nextauth].ts. This catch‑all route handles sign‑in, callbacks, and session management:
import NextAuth from 'next-auth';
import GithubProvider from 'next-auth/providers/github';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
session: {
strategy: 'jwt',
},
callbacks: {
async session({ session, token }) {
if (token.sub) {
session.user.id = token.sub;
}
return session;
},
},
});
Don’t forget to add the required environment variables (GITHUB_ID, GITHUB_SECRET, NEXTAUTH_SECRET) to your .env.local file. With this setup, you can protect any page by checking useSession from next-auth/react.
Example of a protected dashboard page:
import { useSession, signIn, signOut } from 'next-auth/react';
export default function Dashboard() {
const { data: session, status } = useSession();
if (status === 'loading') return <p>Loading...</p>;
if (!session) {
return (
<div>
<p>You must be signed in to view this page.</p>
<button onClick={() => signIn()}>Sign In</button>
</div>
);
}
return (
<div>
<h1>Welcome, {session.user?.name}!</h1>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}
Now your tasks API can be secured by checking the session inside the route handler, ensuring only authenticated users can create or modify data.
Deploying to Vercel
One of Next.js’s biggest advantages is its seamless integration with Vercel, the platform created by the same team. To deploy, push your repository to GitHub, then import the project in the Vercel dashboard. Vercel detects the next.config.js file, installs dependencies, and runs next build automatically.
If you’re using environment variables (e.g., DATABASE_URL, NEXTAUTH_SECRET), add them in the Vercel project settings under “Environment Variables.” Vercel also provides a preview deployment for every pull request, enabling instant feedback on UI changes.
Pro tip: Enable “Incremental Static Regeneration” (ISR) by addingrevalidatetogetStaticProps. This allows you to update static pages without a full rebuild—perfect for content‑heavy blogs.
Performance Optimizations
Next.js already ships with many performance‑boosting defaults: automatic code splitting, image optimization via next/image, and server‑side rendering when needed. However, a few extra steps can push your app into the “ultra‑fast” tier.
- Use
next/imagefor every image. It serves WebP/AVIF when supported and lazy‑loads off‑screen assets. - Leverage
React.memoanduseCallback. Prevent unnecessary re‑renders in component trees that receive frequent props. - Enable HTTP/2 or HTTP/3. Vercel automatically negotiates the best protocol, but custom servers may need explicit configuration.
For data‑heavy pages, consider getStaticProps with revalidate (ISR) or getServerSideProps with caching headers. Example of ISR:
export