Build a Modern Fullstack App with the 2025 Frontend Stack


Never miss an update
Subscribe to receive news and special offers.
By subscribing you agree to our Privacy Policy.
Build fullstack apps with modern tools like Next.js 15, React 19, Tailwind CSS v4, and Shadcn β fast, consistent, and production-ready.
Choosing the right tools in 2025 means picking components that scale, play well together, and reduce boilerplate. Here's what weβll use:
use(), async components, transitionsCreate 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@/ for cleaner importsUse 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>'use server'use() and SuspenseReact 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 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);
}Install Shadcn components:
npx shadcn-ui@latest initUse 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 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 (formerly NextAuth.js) provides a complete authentication solution for your Next.js application with support for various providers and a simple API.
Install Auth.js:
npm install next-auth@betaCreate 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 NextAuthConfigCreate the API route for Auth.js:
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlersCreate a middleware file to protect routes:
// src/middleware.ts
import { getSession } from "@/server/users";
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export async function middleware(request: NextRequest) {
const session = await getSession()
// 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).*)"],
}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>
)
}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>
)
}Access the session in server components:
// src/app/profile/page.tsx
import { getSession } from "@/server/users";
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>
)
}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>
)
}next-themesImplement 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>
)
}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:
This stack represents the best practices in modern web development and will serve you well for projects in 2025 and beyond.
use() + SuspenseGot questions or want a deep dive on a specific topic? Drop a comment or contact me!