Build Next.js 15+ applications with App Router, Server Components, and TypeScript. Generate pages, API routes, server actions, layouts, and deploy to Vercel. Use when creating Next.js apps, full-stack React applications, or when user mentions Next.js, App Router, server components, RSC, Vercel, API routes, server actions, React 19.
A comprehensive skill for building modern Next.js 15+ applications with App Router, React Server Components, TypeScript, and production-ready best practices.
# Create new Next.js 15 app with recommended defaults
pnpm create next-app@latest my-app --yes
# Navigate to project
cd my-app
# Start development server with Turbopack
pnpm dev
The --yes flag enables these defaults:
@/* import alias| Requirement | Version |
|---|---|
| Node.js | 20.9 or later |
| pnpm/npm/yarn | Latest recommended |
| Supported OS | macOS, Windows (WSL), Linux |
| Browser | Minimum Version |
|---|---|
| Chrome | 111+ |
| Edge | 111+ |
| Firefox | 111+ |
| Safari | 16.4+ |
Next.js 15 is a React framework for building full-stack web applications. It provides:
params and searchParams are now Promises| Feature | App Router (Recommended) | Pages Router (Legacy) |
|---|---|---|
| Directory | app/ | pages/ |
| Server Components | Default | Not available |
| Layouts | Nested, preserved | Per-page only |
| Loading States | Built-in | Manual |
| Error Handling | Built-in boundaries | Manual |
| Streaming | Supported | Limited |
Before creating a Next.js application, gather these requirements:
What type of application are you building?
- [ ] Marketing/Landing page
- [ ] Dashboard/Admin panel
- [ ] E-commerce store
- [ ] Blog/Content site
- [ ] SaaS application
- [ ] API-only backend
- [ ] Full-stack application
Do you need authentication?
- [ ] No authentication
- [ ] Email/password
- [ ] OAuth providers (Google, GitHub, etc.)
- [ ] Magic links
- [ ] Enterprise SSO
What database will you use?
- [ ] No database (static site)
- [ ] PostgreSQL
- [ ] MySQL
- [ ] MongoDB
- [ ] SQLite
- [ ] Supabase
- [ ] PlanetScale
What styling approach do you prefer?
- [ ] Tailwind CSS (recommended)
- [ ] CSS Modules
- [ ] Styled Components
- [ ] Emotion
- [ ] Plain CSS
Where will you deploy?
- [ ] Vercel (recommended)
- [ ] AWS
- [ ] Google Cloud
- [ ] Self-hosted
- [ ] Docker
- [ ] Edge (Cloudflare Workers)
What additional features do you need?
- [ ] Internationalization (i18n)
- [ ] Dark mode
- [ ] Analytics
- [ ] Error tracking (Sentry)
- [ ] Email sending
- [ ] File uploads
- [ ] Real-time features
- [ ] PWA support
## Project Requirements
**Project Name**: [name]
**Description**: [brief description]
### Technical Stack
- Next.js: 15.x
- React: 19.x
- TypeScript: Yes/No
- Styling: [Tailwind/CSS Modules/etc.]
- Database: [PostgreSQL/MongoDB/etc.]
- ORM: [Prisma/Drizzle/etc.]
- Auth: [NextAuth/Clerk/etc.]
### Pages/Routes
1. / - Home page
2. /dashboard - Protected dashboard
3. /blog - Blog listing
4. /blog/[slug] - Blog post
5. /api/... - API routes
### Features
- [ ] Feature 1
- [ ] Feature 2
### Deployment
- Platform: Vercel
- Environment: Production/Staging/Development
pnpm create next-app@latest
You'll be prompted for:
src/ directory (optional)pnpm create next-app@latest my-app --yes
cd my-app
# Use starter template
cp -r /path/to/templates/starter-app ./my-app
cd my-app
pnpm install
my-app/
├── app/ # App Router directory
│ ├── layout.tsx # Root layout (required)
│ ├── page.tsx # Home page (/)
│ ├── loading.tsx # Loading UI
│ ├── error.tsx # Error boundary
│ ├── not-found.tsx # 404 page
│ ├── global-error.tsx # Global error boundary
│ ├── actions.ts # Server Actions
│ │
│ ├── (routes)/ # Route groups (optional)
│ │ ├── dashboard/
│ │ │ ├── layout.tsx # Dashboard layout
│ │ │ ├── page.tsx # /dashboard
│ │ │ └── loading.tsx # Dashboard loading
│ │ │
│ │ └── blog/
│ │ ├── page.tsx # /blog
│ │ └── [slug]/
│ │ └── page.tsx # /blog/:slug
│ │
│ └── api/ # API routes
│ ├── health/
│ │ └── route.ts # GET /api/health
│ └── users/
│ ├── route.ts # GET/POST /api/users
│ └── [id]/
│ └── route.ts # GET/PATCH/DELETE /api/users/:id
│
├── components/ # React components
│ ├── ui/ # UI primitives
│ │ ├── button.tsx
│ │ └── input.tsx
│ └── dashboard/ # Feature components
│ ├── stats-cards.tsx
│ └── recent-activity.tsx
│
├── hooks/ # Custom React hooks
│ ├── use-debounce.ts
│ ├── use-local-storage.ts
│ └── use-media-query.ts
│
├── lib/ # Utility functions
│ ├── utils.ts
│ └── api.ts
│
├── types/ # TypeScript types
│ └── index.ts
│
├── styles/ # Global styles
│ └── globals.css
│
├── public/ # Static assets
│ ├── favicon.ico
│ └── images/
│
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind configuration
├── tsconfig.json # TypeScript configuration
├── package.json # Dependencies
├── .env.example # Environment template
├── .env.local # Local environment (gitignored)
└── .gitignore # Git ignore rules
| File | Purpose |
|---|---|
layout.tsx | Shared UI wrapper for route segment |
page.tsx | Unique UI for a route (makes route public) |
loading.tsx | Loading UI (React Suspense boundary) |
error.tsx | Error UI (React Error Boundary) |
not-found.tsx | Not found UI |
route.ts | API endpoint |
template.tsx | Re-rendered layout |
default.tsx | Parallel route fallback |
Create .env.local:
# App Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_NAME="My App"
# API Configuration (server-only)
API_URL=http://localhost:8080
API_SECRET_KEY=your-secret-key
# Database (server-only)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
# Authentication
NEXTAUTH_SECRET=your-nextauth-secret
NEXTAUTH_URL=http://localhost:3000
Important:
NEXT_PUBLIC_ are exposed to the browserEvery Next.js app requires a root layout at app/layout.tsx:
// app/layout.tsx
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import '@/styles/globals.css'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'A Next.js 15 application',
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
}
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#000000' },
],
width: 'device-width',
initialScale: 1,
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
{children}
</body>
</html>
)
}
Components are Server Components by default:
// app/dashboard/page.tsx
// This is a Server Component - runs on server, no client JS
import { db } from '@/lib/db'
export default async function DashboardPage() {
// Direct database access - safe on server
const users = await db.user.findMany()
return (
<main>
<h1>Dashboard</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</main>
)
}
Add "use client" directive for interactivity:
// components/counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
)
}
Add "use client" when you need:
// app/page.tsx (Server Component)
import { Counter } from '@/components/counter' // Client Component
export default function Page() {
// Server-side data fetching
const data = await fetchData()
return (
<div>
{/* Server-rendered content */}
<h1>Welcome</h1>
<p>Data: {data.message}</p>
{/* Client Component for interactivity */}
<Counter />
</div>
)
}
Create loading UI that shows while content loads:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse">
<div className="h-8 w-48 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
)
}
Use Suspense for granular loading states:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Stats, StatsSkeleton } from '@/components/stats'
import { Activity, ActivitySkeleton } from '@/components/activity'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
{/* Stats loads independently */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
{/* Activity loads independently */}
<Suspense fallback={<ActivitySkeleton />}>
<Activity />
</Suspense>
</main>
)
}
Create error boundaries:
// app/dashboard/error.tsx
'use client' // Error components must be Client Components
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Dashboard error:', error)
}, [error])
return (
<div className="p-4 text-center">
<h2 className="text-xl font-bold text-red-600">Something went wrong!</h2>
<p className="text-gray-600 mt-2">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Try again
</button>
</div>
)
}
Custom not found page:
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold">404</h1>
<p className="text-xl mt-4">Page not found</p>
<Link
href="/"
className="mt-6 inline-block px-4 py-2 bg-blue-600 text-white rounded"
>
Go home
</Link>
</div>
</div>
)
}
Layouts persist across navigation:
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar'
import { Header } from '@/components/header'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">{children}</main>
</div>
</div>
)
}
Create dynamic segments with brackets:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
// Generate metadata dynamically
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return {
title: post?.title,
description: post?.excerpt,
}
}
Organize routes without affecting URL:
app/
├── (marketing)/ # Group - not in URL
│ ├── layout.tsx # Shared marketing layout
│ ├── page.tsx # / (home)
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
│
└── (dashboard)/ # Group - not in URL
├── layout.tsx # Shared dashboard layout
├── dashboard/
│ └── page.tsx # /dashboard
└── settings/
└── page.tsx # /settings
Exclude folders from routing with underscore:
app/
├── _components/ # Private - not routable
│ ├── Header.tsx
│ └── Footer.tsx
├── _lib/ # Private - not routable
│ └── utils.ts
└── page.tsx # Routable
// app/posts/page.tsx
// Server Component - fetch directly
interface Post {
id: string
title: string
content: string
}
async function getPosts(): Promise<Post[]> {
const res = await fetch('https://api.example.com/posts', {
// Cache options
cache: 'force-cache', // Default - cache indefinitely
// cache: 'no-store', // No caching
// next: { revalidate: 60 }, // Revalidate every 60 seconds
})
if (!res.ok) {
throw new Error('Failed to fetch posts')
}
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// app/dashboard/page.tsx
async function Dashboard() {
// Parallel fetching - both start immediately
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
])
return (
<div>
<UserCard user={user} />
<PostsList posts={posts} />
<AnalyticsChart data={analytics} />
</div>
)
}
// When data depends on previous request
async function Dashboard() {
// First get user
const user = await getUser()
// Then get user-specific data
const posts = await getPostsByUser(user.id)
return (
<div>
<UserCard user={user} />
<PostsList posts={posts} />
</div>
)
}
Server Actions are async functions that run on the server:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
// Validation schema
const CreatePostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
})
// Form state type
export type FormState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
// Server Action for form submission
export async function createPost(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Extract and validate data
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
}
const validation = CreatePostSchema.safeParse(rawData)
if (!validation.success) {
return {
success: false,
message: 'Validation failed',
errors: validation.error.flatten().fieldErrors,
}
}
try {
// Save to database
await db.post.create({
data: validation.data,
})
// Revalidate cache
revalidatePath('/posts')
return {
success: true,
message: 'Post created successfully!',
}
} catch (error) {
return {
success: false,
message: 'Failed to create post',
}
}
}
// Server Action with redirect
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
redirect('/posts')
}
// components/create-post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost, type FormState } from '@/app/actions'
const initialState: FormState = {
success: false,
message: '',
}
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
)
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
required
disabled={isPending}
/>
{state.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
disabled={isPending}
/>
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{state.message && (
<p className={state.success ? 'text-green-500' : 'text-red-500'}>
{state.message}
</p>
)}
</form>
)
}
// components/like-button.tsx
'use client'
import { useState, useTransition } from 'react'
import { toggleLike } from '@/app/actions'
export function LikeButton({ postId }: { postId: string }) {
const [isLiked, setIsLiked] = useState(false)
const [isPending, startTransition] = useTransition()
const handleClick = () => {
startTransition(async () => {
const result = await toggleLike(postId)
setIsLiked(result.isLiked)
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={isLiked ? 'text-red-500' : 'text-gray-500'}
>
{isPending ? '...' : isLiked ? '❤️' : '🤍'}
</button>
)
}
// Revalidate specific path
import { revalidatePath } from 'next/cache'
revalidatePath('/posts')
// Revalidate specific layout
revalidatePath('/posts', 'layout')
// Revalidate by tag
import { revalidateTag } from 'next/cache'
revalidateTag('posts')
// Using tags in fetch
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
// app/api/health/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
})
}
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})
// GET /api/users
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
})
const total = await db.user.count()
return NextResponse.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
}
// POST /api/users
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validation = CreateUserSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten() },
{ status: 400 }
)
}
const user = await db.user.create({
data: validation.data,
})
return NextResponse.json(user, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
type RouteContext = {
params: Promise<{ id: string }>
}
// GET /api/users/:id
export async function GET(
request: NextRequest,
context: RouteContext
) {
const { id } = await context.params
const user = await db.user.findUnique({ where: { id } })
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return NextResponse.json(user)
}
// PATCH /api/users/:id
export async function PATCH(
request: NextRequest,
context: RouteContext
) {
const { id } = await context.params
const body = await request.json()
const user = await db.user.update({
where: { id },
data: body,
})
return NextResponse.json(user)
}
// DELETE /api/users/:id
export async function DELETE(
request: NextRequest,
context: RouteContext
) {
const { id } = await context.params
await db.user.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}
// app/api/example/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies, headers } from 'next/headers'
export async function GET(request: NextRequest) {
// Access cookies
const cookieStore = await cookies()
const token = cookieStore.get('token')
// Access headers
const headersList = await headers()
const authorization = headersList.get('authorization')
// Access query params
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('q')
// Set cookies in response
const response = NextResponse.json({ success: true })
response.cookies.set('visited', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
})
return response
}
// middleware.ts (at project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Check authentication
const token = request.cookies.get('token')
// Protect dashboard routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// Add custom headers
const response = NextResponse.next()
response.headers.set('x-custom-header', 'value')
return response
}
// Configure which routes use middleware
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
],
}
pnpm add next-auth@beta
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
// Verify credentials
const user = await verifyCredentials(credentials)
return user
},
}),
],
callbacks: {
authorized: async ({ auth }) => {
return !!auth
},
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
</div>
)
}
// components/auth-buttons.tsx
import { signIn, signOut } from '@/auth'
export function SignInButton() {
return (
<form
action={async () => {
'use server'
await signIn('github')
}}
>
<button type="submit">Sign in with GitHub</button>
</form>
)
}
export function SignOutButton() {
return (
<form
action={async () => {
'use server'
await signOut()
}}
>
<button type="submit">Sign out</button>
</form>
)
}
pnpm add -D vitest @vitejs/plugin-react @testing-library/react @testing-library/dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.test.{ts,tsx}'],
coverage: {
reporter: ['text', 'json', 'html'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
})
// vitest.setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
// components/__tests__/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Button } from '../ui/button'
describe('Button', () => {
it('renders correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('handles click events', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('shows loading state', () => {
render(<Button isLoading>Submit</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
// app/api/health/__tests__/route.test.ts
import { describe, it, expect } from 'vitest'
import { GET } from '../route'
describe('GET /api/health', () => {
it('returns health status', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.status).toBe('ok')
expect(data).toHaveProperty('timestamp')
})
})
pnpm add -D @playwright/test
pnpm exec playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Home Page', () => {
test('has correct title', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/Next.js/)
})
test('navigates to dashboard', async ({ page }) => {
await page.goto('/')
await page.click('text=Go to Dashboard')
await expect(page).toHaveURL('/dashboard')
})
})
# Install Vercel CLI
pnpm i -g vercel
# Deploy
vercel
# Deploy to production
vercel --prod
# Add environment variables
vercel env add DATABASE_URL production
vercel env add NEXTAUTH_SECRET production
{
"buildCommand": "pnpm build",
"installCommand": "pnpm install",
"framework": "nextjs",
"regions": ["iad1"],
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET,POST,PUT,DELETE,OPTIONS" }
]
}
]
}
# Dockerfile
FROM node:20-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Dependencies
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
# docker-compose.yml