Build a Modern Fullstack App with the 2025 Frontend Stack

npmixnpmix
34
Modern Frontend Stack 2025

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 19use(), 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:

bash
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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.

ts
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:

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:

bash
1npx shadcn-ui@latest init

Use components like this:

tsx
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:

ts
1const formSchema = z.object({
2  email: z.string().email(),
3  name: z.string().min(2).max(50),
4})

Use it in Server Actions:

ts
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:

bash
1npm install next-auth@beta

Create an authentication configuration file:

ts
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:

ts
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:

ts
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:

ts
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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!

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.