Build a Modern Fullstack App with the 2025 Frontend Stack

npmixnpmix
193
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
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:

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

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

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

tsx
<Suspense fallback={<Loading />}>
  <Dashboard />
</Suspense>

🎨 Tailwind CSS v4 — Theming with OKLCH

Tailwind now supports OKLCH color format, which is more consistent across themes.

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

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:

bash
npx shadcn-ui@latest init

Use components like this:

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

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

Use it in Server Actions:

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

bash
npm install next-auth@beta

Create an authentication configuration file:

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

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

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

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

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

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

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

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

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

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

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

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

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.