Next.js 16 Tutorial: Build a Full-Stack App
A hands-on tutorial to build a complete task manager app using the Next.js 16 App Router, Server Components, Server Actions, Prisma, and TypeScript.

What Next.js fixes
Before Next.js, building a React app with server-side rendering was painful. You'd wire up Express, configure webpack manually, work out code splitting by hand, wrestle with hydration mismatches, and spend days on plumbing before writing a single line of business logic.
Next.js took that pain away, and the App Router changed things in a way that matters. Server Components and Server Actions are the default, not experimental features, which means you can query your database directly inside a component and handle form submissions without writing API routes, and the out-of-the-box performance beats what most of us were hand-rolling before.
In this tutorial we'll build a full-stack task manager from scratch. It's a working app with authentication, a database, server-side rendering, and deployment, so by the end you'll have something running on Vercel that you can use every day. That's the best way to learn any framework. Build something you'd want to open on a Monday morning.
The tech stack:
- Next.js 16 with the App Router (this tutorial was originally written for Next.js 14 and has been updated for 16)
- TypeScript for type safety
- Prisma with SQLite for the database
- Server Components for fast page loads
- Server Actions for mutations
- A proxy (formerly middleware) for route protection
- Tailwind CSS for styling (if you want to level up your utility-class skills, check out our Tailwind CSS tips and tricks guide)
This stack is how modern full-stack React apps get built in production today. Server Components and Server Actions are mainstream now. Understanding them is probably the most important thing for any React developer moving forward. If you're weighing React against other modern frameworks, our comparison of React vs Vue vs Svelte covers the tradeoffs in detail.
Setting up the project
Open your terminal and create a new Next.js project:
npx create-next-app@latest task-manager --typescript --tailwind --eslint --app --src-dir
cd task-manager
When prompted, accept the default options. That gives us a project with TypeScript, Tailwind CSS, ESLint, the App Router, and a src directory structure.
Now install Prisma and a few utilities:
npm install prisma @prisma/client bcryptjs jsonwebtoken
npm install -D @types/bcryptjs @types/jsonwebtoken
Your project structure should look like this:
task-manager/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── globals.css
│ └── ...
├── prisma/
├── public/
├── package.json
├── tsconfig.json
└── tailwind.config.ts
Scaffolding done.
Understanding the App Router
Before we write business logic, you need to understand how routing works in the App Router. The App Router uses file-system-based routing inside the app directory, where every folder becomes a route segment. A page.tsx file inside that folder makes the route publicly accessible. A layout.tsx file wraps the page and persists across navigations within that segment.
A quick mental model:
| File Path | URL Route | Purpose |
|---|---|---|
app/page.tsx | / | Home page |
app/dashboard/page.tsx | /dashboard | Dashboard page |
app/dashboard/layout.tsx | /dashboard/* | Shared layout for dashboard |
app/api/tasks/route.ts | /api/tasks | API endpoint |
app/tasks/[id]/page.tsx | /tasks/123 | Variable-path task page |
The big change from the old Pages Router is that layouts are preserved across navigations. If your dashboard has a sidebar defined in layout.tsx, that sidebar won't re-render when you move between dashboard sub-pages, which is a big performance win and the kind of thing that makes you wonder, once you've seen it, why it wasn't always done this way.
Server Components vs Client Components
This trips up most developers coming from traditional React. In the App Router, every component is a Server Component by default. Server Components render on the server, send HTML to the client, and never ship JavaScript to the browser.
That matters because Server Components can:
- Query your database directly without an API layer
- Access server-side environment variables securely
- Reduce bundle size, since their code never reaches the client
- Fetch data without
useEffectoruseState
Client Components are what you opt into with the "use client" directive at the top of the file. You need them when you're using:
useState,useEffect, or other React hooks- Browser APIs like
windoworlocalStorage - Event handlers like
onClickoronChange - Third-party libraries that depend on browser features
The rule I keep coming back to is keep components on the server unless they absolutely need interactivity. Data-fetching components, layouts and display-only components should all be Server Components, and only forms, modals, dropdowns, and other interactive pieces need to be Client Components. Stick to that. You'll probably end up with a faster app than you expected.
Setting up Prisma and the database
Initialize Prisma with SQLite:
npx prisma init --datasource-provider sqlite
That creates a prisma/schema.prisma file and a .env file. Open the schema and define our models:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
name String
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(cuid())
title String
description String?
completed Boolean @default(false)
priority String @default("medium")
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Your .env file should already have:
DATABASE_URL="file:./dev.db"
Run the migration:
npx prisma migrate dev --name init
Now create a Prisma client singleton. Without one, Next.js hot reloading spawns multiple database connections during development and things get messy fast:
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Database ready.
Building the authentication system
We need user registration and login, so we'll build a simple JWT-based auth system. First, add a secret to your .env:
JWT_SECRET="your-super-secret-key-change-this"
Create the auth utility:
// src/lib/auth.ts
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import { cookies } from 'next/headers'
const JWT_SECRET = process.env.JWT_SECRET!
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword)
}
export function generateToken(userId: string): string {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' })
}
export function verifyToken(token: string): { userId: string } | null {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string }
} catch {
return null
}
}
export async function getCurrentUser() {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
if (!token) return null
const payload = verifyToken(token)
if (!payload) return null
return payload.userId
}
It hashes passwords with bcrypt, signs tokens with JWT, and reads the current user from cookies. Deliberately plain. Auth code is the last place you want to be clever.
Registration Server Action
Server Actions are async functions that run on the server, and you can call them directly from Client Components without any API routes, a bit like RPC calls that Next.js wires up for you. The first time you see it work, it feels like magic.
// src/app/actions/auth.ts
'use server'
import { prisma } from '@/lib/prisma'
import { hashPassword, verifyPassword, generateToken } from '@/lib/auth'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function registerUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const password = formData.get('password') as string
if (!name || !email || !password) {
return { error: 'All fields are required' }
}
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return { error: 'Email already registered' }
}
const hashedPassword = await hashPassword(password)
const user = await prisma.user.create({
data: { name, email, password: hashedPassword }
})
const token = generateToken(user.id)
const cookieStore = await cookies()
cookieStore.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
redirect('/dashboard')
}
export async function loginUser(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
if (!email || !password) {
return { error: 'All fields are required' }
}
const user = await prisma.user.findUnique({
where: { email }
})
if (!user) {
return { error: 'Invalid credentials' }
}
const isValid = await verifyPassword(password, user.password)
if (!isValid) {
return { error: 'Invalid credentials' }
}
const token = generateToken(user.id)
const cookieStore = await cookies()
cookieStore.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
})
redirect('/dashboard')
}
export async function logoutUser() {
const cookieStore = await cookies()
cookieStore.delete('auth-token')
redirect('/login')
}
That one function does the database query, password hashing, cookie setting and redirect, and a form can call it directly. No fetch, no API route, no loading state to manage.
The registration form (Client Component)
This form needs interactivity (state management, form submission handling, error display), so it has to be a Client Component:
// src/app/register/RegisterForm.tsx
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { registerUser } from '@/app/actions/auth'
import Link from 'next/link'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg
hover:bg-blue-700 disabled:opacity-50"
>
{pending ? 'Creating account...' : 'Create Account'}
</button>
)
}
export default function RegisterForm() {
const [state, action] = useFormState(registerUser, null)
return (
<form action={action} className="space-y-4 max-w-md mx-auto mt-20">
<h1 className="text-2xl font-bold">Create Account</h1>
{state?.error && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
<input name="name" type="text" placeholder="Full Name"
className="w-full p-2 border rounded-lg" required />
<input name="email" type="email" placeholder="Email"
className="w-full p-2 border rounded-lg" required />
<input name="password" type="password" placeholder="Password"
className="w-full p-2 border rounded-lg" required minLength={6} />
<SubmitButton />
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Log in
</Link>
</p>
</form>
)
}
Notice the useFormStatus hook in the SubmitButton component: while the form is submitting, the button shows "Creating account..." and disables itself, so you don't need to track loading state manually.
The page file is just a Server Component that renders the form:
// src/app/register/page.tsx
import RegisterForm from './RegisterForm'
export default function RegisterPage() {
return <RegisterForm />
}
Build a similar LoginForm component at src/app/login/LoginForm.tsx using the loginUser action. Same pattern, fewer fields.
Proxy for route protection
A proxy runs before a request completes. In Next.js 16 this file is called proxy.ts and exports a proxy function; it was called middleware.ts in earlier versions, and the codemod (npx @next/codemod@canary upgrade latest) renames it for you. We'll use it to protect routes that require authentication:
// src/proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value
const { pathname } = request.nextUrl
// Protected routes
if (pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// Redirect logged-in users away from auth pages
if (pathname === '/login' || pathname === '/register') {
if (token) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/login', '/register']
}
One caveat. The proxy above only checks that the cookie exists. In Next.js 16 the proxy runs on the Node.js runtime, so you can verify the token right there (with jsonwebtoken or an Edge-friendly library like jose), and in a production app you should, or use an auth library such as Auth.js. For this tutorial the cookie check is enough, since Server Actions verify the token properly when they run. Not perfect, but fine for learning.
Building the task dashboard
This is where Server Components shine. The dashboard page fetches tasks directly from the database, with no API call, no loading state and no useEffect:
// src/app/dashboard/page.tsx
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { redirect } from 'next/navigation'
import TaskList from './TaskList'
import AddTaskForm from './AddTaskForm'
export default async function DashboardPage() {
const userId = await getCurrentUser()
if (!userId) redirect('/login')
const tasks = await prisma.task.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})
const user = await prisma.user.findUnique({
where: { id: userId },
select: { name: true }
})
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">
Welcome, {user?.name}
</h1>
<form action={logoutUser}>
<button className="text-red-500 hover:underline">
Logout
</button>
</form>
</div>
<AddTaskForm />
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">
Your Tasks ({tasks.length})
</h2>
<TaskList tasks={tasks} />
</div>
</div>
)
}
We called prisma.task.findMany() directly in the component, with no API route, fetch call or useEffect. The database query runs on the server, the component renders to HTML, and the client receives the finished page. If you're coming from traditional React, where everything needed a REST endpoint and a loading spinner, this probably feels wrong. Give it a week. Once you're used to the model, going back feels tedious.
Server Actions for task CRUD
Create server actions for managing tasks:
// src/app/actions/tasks.ts
'use server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function createTask(formData: FormData) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
const title = formData.get('title') as string
const description = formData.get('description') as string
const priority = formData.get('priority') as string
const dueDate = formData.get('dueDate') as string
if (!title) return { error: 'Title is required' }
await prisma.task.create({
data: {
title,
description: description || null,
priority: priority || 'medium',
dueDate: dueDate ? new Date(dueDate) : null,
userId
}
})
revalidatePath('/dashboard')
}
export async function toggleTask(taskId: string) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
const task = await prisma.task.findFirst({
where: { id: taskId, userId }
})
if (!task) return { error: 'Task not found' }
await prisma.task.update({
where: { id: taskId },
data: { completed: !task.completed }
})
revalidatePath('/dashboard')
}
export async function deleteTask(taskId: string) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
await prisma.task.deleteMany({
where: { id: taskId, userId }
})
revalidatePath('/dashboard')
}
export async function updateTask(formData: FormData) {
const userId = await getCurrentUser()
if (!userId) return { error: 'Unauthorized' }
const taskId = formData.get('taskId') as string
const title = formData.get('title') as string
const description = formData.get('description') as string
const priority = formData.get('priority') as string
await prisma.task.update({
where: { id: taskId },
data: { title, description, priority }
})
revalidatePath('/dashboard')
}
Pay attention to the revalidatePath('/dashboard') call, which tells Next.js to re-render the dashboard page with fresh data after a mutation. Without it, the page would show stale data until someone manually refreshed.
The add task form
// src/app/dashboard/AddTaskForm.tsx
'use client'
import { useFormStatus } from 'react-dom'
import { createTask } from '@/app/actions/tasks'
import { useRef } from 'react'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg
hover:bg-blue-700 disabled:opacity-50">
{pending ? 'Adding...' : 'Add Task'}
</button>
)
}
export default function AddTaskForm() {
const formRef = useRef<HTMLFormElement>(null)
async function handleSubmit(formData: FormData) {
await createTask(formData)
formRef.current?.reset()
}
return (
<form ref={formRef} action={handleSubmit}
className="bg-gray-50 p-4 rounded-lg space-y-3">
<div className="flex gap-3">
<input name="title" type="text" placeholder="Task title"
className="flex-1 p-2 border rounded-lg" required />
<select name="priority" className="p-2 border rounded-lg">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</div>
<input name="description" type="text"
placeholder="Description (optional)"
className="w-full p-2 border rounded-lg" />
<div className="flex justify-between items-center">
<input name="dueDate" type="date"
className="p-2 border rounded-lg" />
<SubmitButton />
</div>
</form>
)
}
Using useRef to grab the form element lets us reset all fields after a successful submission, and the handleSubmit wrapper around createTask is where that reset happens. Simple, and it works every time.
The task list component
// src/app/dashboard/TaskList.tsx
'use client'
import { toggleTask, deleteTask } from '@/app/actions/tasks'
type Task = {
id: string
title: string
description: string | null
completed: boolean
priority: string
dueDate: Date | null
}
function PriorityBadge({ priority }: { priority: string }) {
const colors: Record<string, string> = {
high: 'bg-red-100 text-red-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700'
}
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[priority]}`}>
{priority}
</span>
)
}
export default function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<p className="text-gray-500 text-center py-8">
No tasks yet. Add one above to get started.
</p>
)
}
return (
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id}
className={`flex items-center gap-3 p-4 border rounded-lg
${task.completed ? 'bg-gray-50 opacity-60' : 'bg-white'}`}>
<button onClick={() => toggleTask(task.id)}
className={`w-5 h-5 rounded border-2 flex-shrink-0
${task.completed
? 'bg-blue-600 border-blue-600'
: 'border-gray-300'}`}
/>
<div className="flex-1">
<p className={`font-medium ${
task.completed ? 'line-through text-gray-400' : ''
}`}>
{task.title}
</p>
{task.description && (
<p className="text-sm text-gray-500">{task.description}</p>
)}
</div>
<PriorityBadge priority={task.priority} />
<button onClick={() => deleteTask(task.id)}
className="text-red-400 hover:text-red-600 text-sm">
Delete
</button>
</div>
))}
</div>
)
}
Each task gets a toggle button and a delete button, and both call Server Actions directly. When a task is toggled or deleted, revalidatePath fires, the dashboard re-renders with fresh data, and the UI updates. I've skipped optimistic updates because the slight delay is fine for a tutorial, though in production you'd probably add useOptimistic for snappier feedback.
Adding API routes
Sometimes you need traditional API endpoints, for webhooks, third-party integrations or mobile apps. Next.js handles these with Route Handlers:
// src/app/api/tasks/route.ts
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { NextResponse } from 'next/server'
export async function GET() {
const userId = await getCurrentUser()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const tasks = await prisma.task.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})
return NextResponse.json(tasks)
}
export async function POST(request: Request) {
const userId = await getCurrentUser()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const task = await prisma.task.create({
data: {
title: body.title,
description: body.description || null,
priority: body.priority || 'medium',
dueDate: body.dueDate ? new Date(body.dueDate) : null,
userId
}
})
return NextResponse.json(task, { status: 201 })
}
Each exported function name maps to an HTTP method, and GET, POST, PUT, DELETE and PATCH all work. The request object is the standard Web API Request; you return a NextResponse. Familiar territory if you've used Express. For a broader look at designing clean, scalable API contracts, our guide on REST and GraphQL API design best practices is a useful companion read.
The dashboard layout
Create a layout that wraps all dashboard pages with consistent navigation:
// src/app/dashboard/layout.tsx
import { logoutUser } from '@/app/actions/auth'
export default function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-4xl mx-auto px-6 py-3 flex justify-between">
<h1 className="font-bold text-lg">Task Manager</h1>
<form action={logoutUser}>
<button className="text-sm text-gray-500 hover:text-red-500">
Sign Out
</button>
</form>
</div>
</nav>
<main className="py-6">{children}</main>
</div>
)
}
This is the layout behaviour from earlier in action. When you move between dashboard sub-pages, the navbar doesn't re-render and only the {children} content swaps out. Transitions feel instant.
Data fetching patterns
Next.js gives you several ways to fetch data, and it helps to know when to use each one.
Server Component direct fetch
Use this when the data is needed on initial page load. It runs on the server, renders into HTML and ships to the client:
// This is a Server Component (default)
async function TaskStats() {
const stats = await prisma.task.groupBy({
by: ['priority'],
_count: { id: true }
})
return <StatsChart data={stats} />
}
You get the data rendered without a loading spinner, a useEffect or race conditions.
Server Actions
Use these when you're mutating data (creating, updating, deleting). They're called from forms or Client Components:
'use server'
export async function markAllComplete() {
await prisma.task.updateMany({
where: { userId: currentUserId, completed: false },
data: { completed: true }
})
revalidatePath('/dashboard')
}
Route Handlers
Use these when you need a traditional REST API: for external consumers, webhooks, or cases where Client Components need to fetch data on the fly.
Client-side fetching
Using fetch inside a useEffect still works for real-time updates or data that changes frequently without page reloads. It's the old pattern, though, and most of the time Server Components and Server Actions cover your needs better, so avoid it unless you really need it.
Deploying to Vercel
Deploying a Next.js app to Vercel is about as simple as deployment gets. First, push your code to a GitHub repository:
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/yourusername/task-manager.git
git push -u origin main
Then head to vercel.com, import the repository, and deploy. Vercel auto-detects Next.js and configures everything, and if your repo is clean the whole process takes under two minutes.
One catch: SQLite won't work on Vercel because the filesystem is ephemeral, so you need a hosted database. The easiest options:
- Vercel Postgres is gone as a first-party product (existing databases moved to Neon in December 2024), but you can add Neon or another Postgres provider from the Vercel Marketplace and the environment variables get wired into your project for you
- PlanetScale is MySQL-compatible with a great developer experience, though it dropped its free Hobby plan in April 2024, so budget for a paid plan
- Supabase gives you PostgreSQL with a nice dashboard
Update your Prisma schema provider and DATABASE_URL accordingly. For Postgres (Neon through the Marketplace, say):
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
Set your environment variables in Vercel's dashboard, run npx prisma migrate deploy, and your app is on the internet. Probably faster than configuring a traditional server.
Performance tips
A few things that matter when running Next.js apps in production. Some seem obvious. People miss them anyway.
-
Use
loading.tsxfiles for instant loading states. Place aloading.tsxalongside yourpage.tsx, and Next.js shows it immediately while the page data loads. Users see something right away instead of a blank screen. It makes a bigger difference than you'd expect. -
Use parallel routes for dashboards with multiple data-heavy sections. Each section loads independently, so a slow database query in one panel doesn't block the others.
-
Use
generateStaticParamsfor pages with parameterized routes that can be pre-rendered at build time, like product pages, blog posts, documentation, or anything else that doesn't change on every request. -
Keep Client Components small. Every Client Component boundary ships JavaScript to the browser, so push interactivity out to the leaf nodes of your component tree. A lot of bundle size problems come from turning entire page sections into Client Components when only a button inside them needed interactivity.
-
Cache expensive queries. For database queries that don't need real-time data, Next.js 16 adds the
"use cache"directive (enablecacheComponentsinnext.config.ts), which makes caching explicit at the function or component level. The olderunstable_cachestill works if you're not ready to switch.
Where to go from here
We covered the App Router's file-based routing, Server Components versus Client Components, Prisma for database operations, Server Actions for mutations, a proxy for route protection, API routes for external consumers, and deploying to production. That's a full-stack application from start to finish.
A tutorial only gets you so far. Reading about Server Components is one thing; wrestling with them in your own project, at midnight, with a hydration error you can't explain, is another. So take this task manager and change it before you move on. Add task categories, build a drag-and-drop board with a Client Component library, wire up email notifications, or break something and fix it.
The patterns we built here (Server Components for data display, Server Actions for mutations, a proxy for auth, Prisma for database access) are the ones the Next.js docs steer you toward for production apps. You'll learn them properly by building with them.
Anurag Sinha
Founder & Editor
Founder and editor of Tech Tips India. Writes tech guides, buying guides and explainers for Indian readers, with rupee pricing and local availability. Product roundups are researched from manufacturer specs and published reviews, not lab testing.
Stay Ahead in Tech
Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.
No spam ever. Unsubscribe anytime.
Comments (0)
Leave a Comment
All comments are moderated before appearing. Please be respectful and follow our community guidelines.
Related Articles

API Design: REST and GraphQL Patterns That Scale
API design guide covering REST, GraphQL, tRPC, authentication, rate limiting, error handling, and testing with Node.js examples.

DSA Roadmap for Placements: What Matters in 2026
Practical DSA roadmap for campus placements in India: topic priorities, platform choices, company patterns, and time management.

Getting Started with AI in 2026: A Beginner's Complete Guide
AI is changing every industry. Learn how it works, the popular tools, and how to start your own AI journey in 2026.