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:
1npx create-next-app@latest your-app-name \
2 --app --ts --tailwind --eslint --src-dir \
3 --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:
1// src/app/actions/subscribe.ts
2'use server'
3
4export async function subscribe(formData: FormData) {
5 const email = formData.get('email')?.toString()
6 // Add email to database or list
7}
Then call it in a component:
1<form action={subscribe}>
2 <input name="email" type="email" required />
3 <button type="submit">Subscribe</button>
4</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:
1const 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:
1<Suspense fallback={<Loading />}>
2 <Dashboard />
3</Suspense>
🎨 Tailwind CSS v4 — Theming with OKLCH
Tailwind now supports OKLCH color format, which is more consistent across themes.
1// tailwind.config.ts
2import { oklchPlugin } from '@tokey/css-oklch'
3
4export default {
5 theme: {
6 extend: {
7 colors: {
8 primary: 'oklch(60% 0.1 220)',
9 }
10 }
11 },
12 plugins: [oklchPlugin]
13}
Set up theme variables in globals.css
:
1:root {
2 --color-primary: oklch(60% 0.1 220);
3}
4.dark {
5 --color-primary: oklch(80% 0.05 330);
6}
🧩 Shadcn/ui — Accessible UI Components
Install Shadcn components:
1npx shadcn-ui@latest init
Use components like this:
1import { Button } from '@/components/ui/button'
2
3<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:
1const formSchema = z.object({
2 email: z.string().email(),
3 name: z.string().min(2).max(50),
4})
Use it in Server Actions:
1'use server'
2
3import { formSchema } from './schema'
4
5export async function submit(data: FormData) {
6 const parsed = formSchema.safeParse({
7 email: data.get('email'),
8 name: data.get('name'),
9 })
10
11 if (!parsed.success) return { error: parsed.error }
12 // Save to database
13}
🔒 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:
1npm install next-auth@beta
Create an authentication configuration file:
1// src/auth.ts
2import NextAuth from "next-auth"
3import { PrismaAdapter } from "@auth/prisma-adapter"
4import { db } from "@/lib/db"
5import authConfig from "@/auth.config"
6
7export const {
8 handlers,
9 auth,
10 signIn,
11 signOut
12} = NextAuth({
13 adapter: PrismaAdapter(db),
14 session: { strategy: "jwt" },
15 ...authConfig,
16})
Create a separate configuration file for providers:
1// src/auth.config.ts
2import type { NextAuthConfig } from "next-auth"
3import Credentials from "next-auth/providers/credentials"
4import Google from "next-auth/providers/google"
5import GitHub from "next-auth/providers/github"
6import { z } from "zod"
7import { compare } from "bcrypt"
8import { db } from "@/lib/db"
9
10export default {
11 providers: [
12 Google({
13 clientId: process.env.GOOGLE_CLIENT_ID,
14 clientSecret: process.env.GOOGLE_CLIENT_SECRET,
15 }),
16 GitHub({
17 clientId: process.env.GITHUB_CLIENT_ID,
18 clientSecret: process.env.GITHUB_CLIENT_SECRET,
19 }),
20 Credentials({
21 async authorize(credentials) {
22 // Define a schema for credentials validation
23 const parsedCredentials = z
24 .object({ email: z.string().email(), password: z.string().min(8) })
25 .safeParse(credentials)
26
27 if (!parsedCredentials.success) {
28 return null
29 }
30
31 const { email, password } = parsedCredentials.data
32
33 // Find the user in the database
34 const user = await db.user.findUnique({
35 where: { email },
36 })
37
38 // If user doesn't exist or password doesn't match
39 if (!user || !user.password) {
40 return null
41 }
42
43 // Compare the provided password with the stored hash
44 const passwordMatch = await compare(password, user.password)
45
46 if (!passwordMatch) {
47 return null
48 }
49
50 // Return the user object without the password
51 return {
52 id: user.id,
53 email: user.email,
54 name: user.name,
55 role: user.role,
56 }
57 },
58 }),
59 ],
60 callbacks: {
61 async session({ session, token }) {
62 // Add user role to the session
63 if (token.sub && session.user) {
64 session.user.id = token.sub
65 session.user.role = token.role as string
66 }
67 return session
68 },
69 async jwt({ token }) {
70 if (!token.sub) return token
71
72 // Fetch user from database and add role to token
73 const user = await db.user.findUnique({
74 where: { id: token.sub },
75 })
76
77 if (user) {
78 token.role = user.role
79 }
80
81 return token
82 },
83 },
84 pages: {
85 signIn: "/login",
86 error: "/error",
87 },
88} satisfies NextAuthConfig
Setting Up API Routes
Create the API route for Auth.js:
1// src/app/api/auth/[...nextauth]/route.ts
2import { handlers } from "@/auth"
3
4export const { GET, POST } = handlers
Middleware for Protected Routes
Create a middleware file to protect routes:
1// src/middleware.ts
2import { auth } from "@/auth"
3import { NextResponse } from "next/server"
4import type { NextRequest } from "next/server"
5
6export async function middleware(request: NextRequest) {
7 const session = await auth()
8
9 // Public paths that don't require authentication
10 const publicPaths = ["/", "/login", "/register", "/api/auth"]
11
12 const isPublicPath = publicPaths.some(path =>
13 request.nextUrl.pathname.startsWith(path)
14 )
15
16 // If the path is public or user is authenticated, allow access
17 if (isPublicPath || session) {
18 return NextResponse.next()
19 }
20
21 // Redirect to login if not authenticated
22 const loginUrl = new URL("/login", request.url)
23 loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname)
24 return NextResponse.redirect(loginUrl)
25}
26
27export const config = {
28 matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
29}
Creating Login and Registration Pages
Create a login page:
1// src/app/login/page.tsx
2"use client"
3
4import { useState } from "react"
5import { signIn } from "next-auth/react"
6import { useRouter, useSearchParams } from "next/navigation"
7import { Button } from "@/components/ui/button"
8import { Input } from "@/components/ui/input"
9import { Label } from "@/components/ui/label"
10import { Separator } from "@/components/ui/separator"
11import { FaGoogle, FaGithub } from "react-icons/fa"
12
13export default function LoginPage() {
14 const router = useRouter()
15 const searchParams = useSearchParams()
16 const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"
17
18 const [email, setEmail] = useState("")
19 const [password, setPassword] = useState("")
20 const [error, setError] = useState<string | null>(null)
21 const [isLoading, setIsLoading] = useState(false)
22
23 const handleSubmit = async (e: React.FormEvent) => {
24 e.preventDefault()
25 setIsLoading(true)
26 setError(null)
27
28 try {
29 const result = await signIn("credentials", {
30 email,
31 password,
32 redirect: false,
33 })
34
35 if (result?.error) {
36 setError("Invalid email or password")
37 setIsLoading(false)
38 return
39 }
40
41 router.push(callbackUrl)
42 } catch (error) {
43 setError("An error occurred. Please try again.")
44 setIsLoading(false)
45 }
46 }
47
48 return (
49 <div className="mx-auto max-w-md space-y-6 p-8">
50 <div className="space-y-2 text-center">
51 <h1 className="text-3xl font-bold">Login</h1>
52 <p className="text-gray-500">Enter your credentials to access your account</p>
53 </div>
54
55 {error && (
56 <div className="bg-destructive/10 text-destructive p-3 rounded-md text-sm">
57 {error}
58 </div>
59 )}
60
61 <form onSubmit={handleSubmit} className="space-y-4">
62 <div className="space-y-2">
63 <Label htmlFor="email">Email</Label>
64 <Input
65 id="email"
66 type="email"
67 placeholder="name@example.com"
68 value={email}
69 onChange={(e) => setEmail(e.target.value)}
70 required
71 />
72 </div>
73 <div className="space-y-2">
74 <div className="flex items-center justify-between">
75 <Label htmlFor="password">Password</Label>
76 <a href="/forgot-password" className="text-sm text-primary hover:underline">
77 Forgot password?
78 </a>
79 </div>
80 <Input
81 id="password"
82 type="password"
83 value={password}
84 onChange={(e) => setPassword(e.target.value)}
85 required
86 />
87 </div>
88 <Button type="submit" className="w-full" disabled={isLoading}>
89 {isLoading ? "Signing in..." : "Sign in"}
90 </Button>
91 </form>
92
93 <div className="relative">
94 <div className="absolute inset-0 flex items-center">
95 <Separator className="w-full" />
96 </div>
97 <div className="relative flex justify-center text-xs uppercase">
98 <span className="bg-background px-2 text-muted-foreground">
99 Or continue with
100 </span>
101 </div>
102 </div>
103
104 <div className="grid grid-cols-2 gap-4">
105 <Button
106 variant="outline"
107 onClick={() => signIn("google", { callbackUrl })}
108 disabled={isLoading}
109 >
110 <FaGoogle className="mr-2 h-4 w-4" />
111 Google
112 </Button>
113 <Button
114 variant="outline"
115 onClick={() => signIn("github", { callbackUrl })}
116 disabled={isLoading}
117 >
118 <FaGithub className="mr-2 h-4 w-4" />
119 GitHub
120 </Button>
121 </div>
122
123 <div className="text-center text-sm">
124 Don't have an account?{" "}
125 <a href="/register" className="text-primary hover:underline">
126 Sign up
127 </a>
128 </div>
129 </div>
130 )
131}
Using Authentication in Components
Create a client component to display user information:
1// src/components/user-info.tsx
2"use client"
3
4import { useSession, signOut } from "next-auth/react"
5import { Button } from "@/components/ui/button"
6import {
7 DropdownMenu,
8 DropdownMenuContent,
9 DropdownMenuItem,
10 DropdownMenuLabel,
11 DropdownMenuSeparator,
12 DropdownMenuTrigger,
13} from "@/components/ui/dropdown-menu"
14import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
15
16export function UserInfo() {
17 const { data: session, status } = useSession()
18
19 if (status === "loading") {
20 return <div className="h-8 w-8 rounded-full bg-muted animate-pulse" />
21 }
22
23 if (status === "unauthenticated" || !session?.user) {
24 return (
25 <Button variant="outline" asChild>
26 <a href="/login">Sign in</a>
27 </Button>
28 )
29 }
30
31 const { user } = session
32
33 return (
34 <DropdownMenu>
35 <DropdownMenuTrigger asChild>
36 <Button variant="ghost" className="relative h-8 w-8 rounded-full">
37 <Avatar className="h-8 w-8">
38 <AvatarImage src={user.image || undefined} alt={user.name || "User"} />
39 <AvatarFallback>
40 {user.name?.charAt(0) || user.email?.charAt(0) || "U"}
41 </AvatarFallback>
42 </Avatar>
43 </Button>
44 </DropdownMenuTrigger>
45 <DropdownMenuContent align="end">
46 <DropdownMenuLabel>
47 <div className="flex flex-col space-y-1">
48 <p className="text-sm font-medium leading-none">{user.name}</p>
49 <p className="text-xs leading-none text-muted-foreground">
50 {user.email}
51 </p>
52 </div>
53 </DropdownMenuLabel>
54 <DropdownMenuSeparator />
55 <DropdownMenuItem asChild>
56 <a href="/profile">Profile</a>
57 </DropdownMenuItem>
58 <DropdownMenuItem asChild>
59 <a href="/dashboard">Dashboard</a>
60 </DropdownMenuItem>
61 <DropdownMenuItem asChild>
62 <a href="/settings">Settings</a>
63 </DropdownMenuItem>
64 <DropdownMenuSeparator />
65 <DropdownMenuItem
66 className="text-red-500 focus:text-red-500"
67 onSelect={(e) => {
68 e.preventDefault()
69 signOut({ callbackUrl: "/" })
70 }}
71 >
72 Sign out
73 </DropdownMenuItem>
74 </DropdownMenuContent>
75 </DropdownMenu>
76 )
77}
Server-Side Authentication
Access the session in server components:
1// src/app/profile/page.tsx
2import { auth } from "@/auth"
3import { redirect } from "next/navigation"
4import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
5import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
6import { db } from "@/lib/db"
7
8export default async function ProfilePage() {
9 const session = await auth()
10
11 if (!session?.user) {
12 redirect("/login?callbackUrl=/profile")
13 }
14
15 // Fetch additional user data from database
16 const user = await db.user.findUnique({
17 where: { id: session.user.id },
18 include: { profile: true },
19 })
20
21 return (
22 <div className="container mx-auto py-10">
23 <Card className="max-w-md mx-auto">
24 <CardHeader className="flex flex-row items-center gap-4">
25 <Avatar className="h-14 w-14">
26 <AvatarImage src={user?.profile?.avatar || undefined} alt={user?.name || "User"} />
27 <AvatarFallback className="text-lg">
28 {user?.name?.charAt(0) || user?.email?.charAt(0) || "U"}
29 </AvatarFallback>
30 </Avatar>
31 <div>
32 <CardTitle>{user?.name}</CardTitle>
33 <p className="text-sm text-muted-foreground">{user?.email}</p>
34 </div>
35 </CardHeader>
36 <CardContent className="space-y-4">
37 {user?.profile?.bio && (
38 <div>
39 <h3 className="font-medium">Bio</h3>
40 <p className="text-sm text-muted-foreground mt-1">{user.profile.bio}</p>
41 </div>
42 )}
43 <div>
44 <h3 className="font-medium">Role</h3>
45 <p className="text-sm text-muted-foreground mt-1 capitalize">{user?.role.toLowerCase()}</p>
46 </div>
47 <div>
48 <h3 className="font-medium">Member since</h3>
49 <p className="text-sm text-muted-foreground mt-1">
50 {user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : "N/A"}
51 </p>
52 </div>
53 </CardContent>
54 </Card>
55 </div>
56 )
57}
Role-Based Authorization
Create a higher-order component for role-based access control:
1// src/components/role-gate.tsx
2"use client"
3
4import { useSession } from "next-auth/react"
5import { ReactNode } from "react"
6
7interface RoleGateProps {
8 children: ReactNode
9 allowedRoles: string[]
10 fallback?: ReactNode
11}
12
13export function RoleGate({ children, allowedRoles, fallback }: RoleGateProps) {
14 const { data: session, status } = useSession()
15
16 if (status === "loading") {
17 return <div>Loading...</div>
18 }
19
20 if (status === "unauthenticated" || !session?.user) {
21 return fallback || null
22 }
23
24 const userRole = session.user.role as string
25
26 if (!allowedRoles.includes(userRole)) {
27 return fallback || null
28 }
29
30 return <>{children}</>
31}
Use it in a component:
1// src/app/admin/page.tsx
2"use client"
3
4import { RoleGate } from "@/components/role-gate"
5import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
6import { AlertTriangle } from "lucide-react"
7
8export default function AdminPage() {
9 return (
10 <div className="container mx-auto py-10">
11 <RoleGate
12 allowedRoles={["ADMIN"]}
13 fallback={
14 <Alert variant="destructive">
15 <AlertTriangle className="h-4 w-4" />
16 <AlertTitle>Access Denied</AlertTitle>
17 <AlertDescription>
18 You don't have permission to view this page.
19 </AlertDescription>
20 </Alert>
21 }
22 >
23 <h1 className="text-2xl font-bold mb-6">Admin Dashboard</h1>
24 <p>This content is only visible to administrators.</p>
25 {/* Admin-only content */}
26 </RoleGate>
27 </div>
28 )
29}
🌙 Dark Mode with next-themes
Implement dark mode in your application using next-themes
:
1// src/providers/theme-provider.tsx
2"use client"
3
4import { ThemeProvider as NextThemesProvider } from "next-themes"
5import { type ThemeProviderProps } from "next-themes/dist/types"
6
7export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 return <NextThemesProvider {...props}>{children}</NextThemesProvider>
9}
Add the provider to your layout:
1// src/app/layout.tsx
2import { ThemeProvider } from "@/providers/theme-provider"
3
4export default function RootLayout({
5 children,
6}: {
7 children: React.ReactNode
8}) {
9 return (
10 <html lang="en" suppressHydrationWarning>
11 <body>
12 <ThemeProvider
13 attribute="class"
14 defaultTheme="system"
15 enableSystem
16 disableTransitionOnChange
17 >
18 {children}
19 </ThemeProvider>
20 </body>
21 </html>
22 )
23}
Create a theme toggle component:
1// src/components/theme-toggle.tsx
2"use client"
3
4import { useTheme } from "next-themes"
5import { Button } from "@/components/ui/button"
6import { Moon, Sun } from "lucide-react"
7
8export function ThemeToggle() {
9 const { theme, setTheme } = useTheme()
10
11 return (
12 <Button
13 variant="ghost"
14 size="icon"
15 onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
16 >
17 <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
18 <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
19 <span className="sr-only">Toggle theme</span>
20 </Button>
21 )
22}
🏁 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!