Build a Modern Fullstack App with the 2025 Frontend Stack

Build fullstack apps with modern tools like Next.js 15, React 19, Tailwind CSS v4, and Shadcn — fast, consistent, and production-ready.
🧱 The Modern Frontend Stack: Why These Tools?
Choosing the right tools in 2025 means picking components that scale, play well together, and reduce boilerplate. Here's what we’ll use:
- Next.js 15 – App Router, Server Actions, Turbopack
- React 19 –
use()
, async components, transitions - Tailwind CSS v4 – OKLCH colors, performance
- Shadcn/ui – Headless components styled with Tailwind
- Zod – Full-stack schema validation
- next-themes – Dark mode, theme switching
🧭 Project Setup
Create a new Next.js project with the App Router and all the tools:
npx create-next-app@latest your-app-name \
--app --ts --tailwind --eslint --src-dir \
--import-alias "@/*"
This gives you:
/src/app
– App Router layout- TypeScript & Tailwind pre-configured
- Alias
@/
for cleaner imports
⚙️ Next.js 15 — Server Actions & Routing
Setup
Use the app/
directory for layouts, pages, and routing. Add a basic server action:
// src/app/actions/subscribe.ts
'use server'
export async function subscribe(formData: FormData) {
const email = formData.get('email')?.toString()
// Add email to database or list
}
Then call it in a component:
<form action={subscribe}>
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>
Pitfalls
- Actions must be marked with
'use server'
- Must be async functions
⚛️ React 19 — Embracing use()
and Suspense
React 19 introduces use()
to simplify data fetching:
const data = use(fetchUser())
You can use it in Server Components to fetch data inline without external hooks.
Also, use <Suspense>
to lazy-load parts of your UI:
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
🎨 Tailwind CSS v4 — Theming with OKLCH
Tailwind now supports OKLCH color format, which is more consistent across themes.
// tailwind.config.ts
import { oklchPlugin } from '@tokey/css-oklch'
export default {
theme: {
extend: {
colors: {
primary: 'oklch(60% 0.1 220)',
}
}
},
plugins: [oklchPlugin]
}
Set up theme variables in globals.css
:
:root {
--color-primary: oklch(60% 0.1 220);
}
.dark {
--color-primary: oklch(80% 0.05 330);
}
🧩 Shadcn/ui — Accessible UI Components
Install Shadcn components:
npx shadcn-ui@latest init
Use components like this:
import { Button } from '@/components/ui/button'
<Button>Click me</Button>
No need to handle forwardRef
or styling — just use and customize.
🛡️ Zod — End-to-End Validation
Zod allows you to reuse the same schema client and server-side:
const formSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
})
Use it in Server Actions:
'use server'
import { formSchema } from './schema'
export async function submit(data: FormData) {
const parsed = formSchema.safeParse({
email: data.get('email'),
name: data.get('name'),
})
if (!parsed.success) return { error: parsed.error }
// Save to database
}
🔒 Auth.js — Secure Authentication
Auth.js (formerly NextAuth.js) provides a complete authentication solution for your Next.js application with support for various providers and a simple API.
Setting Up Auth.js
Install Auth.js:
npm install next-auth@beta
Create an authentication configuration file:
// src/auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { db } from "@/lib/db"
import authConfig from "@/auth.config"
export const {
handlers,
auth,
signIn,
signOut
} = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "jwt" },
...authConfig,
})
Create a separate configuration file for providers:
// src/auth.config.ts
import type { NextAuthConfig } from "next-auth"
import Credentials from "next-auth/providers/credentials"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import { z } from "zod"
import { compare } from "bcrypt"
import { db } from "@/lib/db"
export default {
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Credentials({
async authorize(credentials) {
// Define a schema for credentials validation
const parsedCredentials = z
.object({ email: z.string().email(), password: z.string().min(8) })
.safeParse(credentials)
if (!parsedCredentials.success) {
return null
}
const { email, password } = parsedCredentials.data
// Find the user in the database
const user = await db.user.findUnique({
where: { email },
})
// If user doesn't exist or password doesn't match
if (!user || !user.password) {
return null
}
// Compare the provided password with the stored hash
const passwordMatch = await compare(password, user.password)
if (!passwordMatch) {
return null
}
// Return the user object without the password
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
callbacks: {
async session({ session, token }) {
// Add user role to the session
if (token.sub && session.user) {
session.user.id = token.sub
session.user.role = token.role as string
}
return session
},
async jwt({ token }) {
if (!token.sub) return token
// Fetch user from database and add role to token
const user = await db.user.findUnique({
where: { id: token.sub },
})
if (user) {
token.role = user.role
}
return token
},
},
pages: {
signIn: "/login",
error: "/error",
},
} satisfies NextAuthConfig
Setting Up API Routes
Create the API route for Auth.js:
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
Middleware for Protected Routes
Create a middleware file to protect routes:
// src/middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export async function middleware(request: NextRequest) {
const session = await auth()
// Public paths that don't require authentication
const publicPaths = ["/", "/login", "/register", "/api/auth"]
const isPublicPath = publicPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
)
// If the path is public or user is authenticated, allow access
if (isPublicPath || session) {
return NextResponse.next()
}
// Redirect to login if not authenticated
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}
Creating Login and Registration Pages
Create a login page:
// src/app/login/page.tsx
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { FaGoogle, FaGithub } from "react-icons/fa"
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
setIsLoading(false)
return
}
router.push(callbackUrl)
} catch (error) {
setError("An error occurred. Please try again.")
setIsLoading(false)
}
}
return (
<div className="mx-auto max-w-md space-y-6 p-8">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-gray-500">Enter your credentials to access your account</p>
</div>
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-md text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a href="/forgot-password" className="text-sm text-primary hover:underline">
Forgot password?
</a>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
onClick={() => signIn("google", { callbackUrl })}
disabled={isLoading}
>
<FaGoogle className="mr-2 h-4 w-4" />
Google
</Button>
<Button
variant="outline"
onClick={() => signIn("github", { callbackUrl })}
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
GitHub
</Button>
</div>
<div className="text-center text-sm">
Don't have an account?{" "}
<a href="/register" className="text-primary hover:underline">
Sign up
</a>
</div>
</div>
)
}
Using Authentication in Components
Create a client component to display user information:
// src/components/user-info.tsx
"use client"
import { useSession, signOut } from "next-auth/react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export function UserInfo() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div className="h-8 w-8 rounded-full bg-muted animate-pulse" />
}
if (status === "unauthenticated" || !session?.user) {
return (
<Button variant="outline" asChild>
<a href="/login">Sign in</a>
</Button>
)
}
const { user } = session
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.image || undefined} alt={user.name || "User"} />
<AvatarFallback>
{user.name?.charAt(0) || user.email?.charAt(0) || "U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="/profile">Profile</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/dashboard">Dashboard</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/settings">Settings</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-500 focus:text-red-500"
onSelect={(e) => {
e.preventDefault()
signOut({ callbackUrl: "/" })
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Server-Side Authentication
Access the session in server components:
// src/app/profile/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { db } from "@/lib/db"
export default async function ProfilePage() {
const session = await auth()
if (!session?.user) {
redirect("/login?callbackUrl=/profile")
}
// Fetch additional user data from database
const user = await db.user.findUnique({
where: { id: session.user.id },
include: { profile: true },
})
return (
<div className="container mx-auto py-10">
<Card className="max-w-md mx-auto">
<CardHeader className="flex flex-row items-center gap-4">
<Avatar className="h-14 w-14">
<AvatarImage src={user?.profile?.avatar || undefined} alt={user?.name || "User"} />
<AvatarFallback className="text-lg">
{user?.name?.charAt(0) || user?.email?.charAt(0) || "U"}
</AvatarFallback>
</Avatar>
<div>
<CardTitle>{user?.name}</CardTitle>
<p className="text-sm text-muted-foreground">{user?.email}</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
{user?.profile?.bio && (
<div>
<h3 className="font-medium">Bio</h3>
<p className="text-sm text-muted-foreground mt-1">{user.profile.bio}</p>
</div>
)}
<div>
<h3 className="font-medium">Role</h3>
<p className="text-sm text-muted-foreground mt-1 capitalize">{user?.role.toLowerCase()}</p>
</div>
<div>
<h3 className="font-medium">Member since</h3>
<p className="text-sm text-muted-foreground mt-1">
{user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : "N/A"}
</p>
</div>
</CardContent>
</Card>
</div>
)
}
Role-Based Authorization
Create a higher-order component for role-based access control:
// src/components/role-gate.tsx
"use client"
import { useSession } from "next-auth/react"
import { ReactNode } from "react"
interface RoleGateProps {
children: ReactNode
allowedRoles: string[]
fallback?: ReactNode
}
export function RoleGate({ children, allowedRoles, fallback }: RoleGateProps) {
const { data: session, status } = useSession()
if (status === "loading") {
return <div>Loading...</div>
}
if (status === "unauthenticated" || !session?.user) {
return fallback || null
}
const userRole = session.user.role as string
if (!allowedRoles.includes(userRole)) {
return fallback || null
}
return <>{children}</>
}
Use it in a component:
// src/app/admin/page.tsx
"use client"
import { RoleGate } from "@/components/role-gate"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertTriangle } from "lucide-react"
export default function AdminPage() {
return (
<div className="container mx-auto py-10">
<RoleGate
allowedRoles={["ADMIN"]}
fallback={
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
You don't have permission to view this page.
</AlertDescription>
</Alert>
}
>
<h1 className="text-2xl font-bold mb-6">Admin Dashboard</h1>
<p>This content is only visible to administrators.</p>
{/* Admin-only content */}
</RoleGate>
</div>
)
}
🌙 Dark Mode with next-themes
Implement dark mode in your application using next-themes
:
// src/providers/theme-provider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
Add the provider to your layout:
// src/app/layout.tsx
import { ThemeProvider } from "@/providers/theme-provider"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
Create a theme toggle component:
// src/components/theme-toggle.tsx
"use client"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Moon, Sun } from "lucide-react"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
🏁 Conclusion
The 2025 Frontend Stack provides a powerful, type-safe, and developer-friendly foundation for building modern web applications. By combining Next.js, React, Tailwind CSS, Shadcn/ui, Prisma, TanStack Query, Auth.js, and Zod, you get:
- Type safety across your entire stack, from database to UI
- Performance through server components and optimized rendering
- Developer experience with intuitive APIs and excellent tooling
- Scalability for applications of any size
- Accessibility built into the component library
- Security with robust authentication and authorization
This stack represents the best practices in modern web development and will serve you well for projects in 2025 and beyond.
📋 Project Checklist
- ✅ Next.js 15 with App Router and Server Components
- ✅ React 19 with
use()
+ Suspense - ✅ Tailwind v4 with OKLCH + dark mode
- ✅ Shadcn components fully integrated
- ✅ Zod for runtime-safe validation
- ✅ Prisma for type-safe database access
- ✅ TanStack Query for data fetching and caching
- ✅ Auth.js for secure authentication
Got questions or want a deep dive on a specific topic? Drop a comment or contact me!