TopFrontDev logo
HomeVideosPlaylistsResourcesArticlesAbout
Youtube logoSubscribe

TopFrontDev

Front-end techniques that actually ship. Practical engineering you can use in production.

Content

  • Videos
  • Playlists
  • Articles
  • Resources

About

  • About
  • Sponsorship
  • Contact

Connect

Youtube logogithub logolinkedin logo

© 2026 TopFrontDev. All rights reserved.

TopFrontDev logo
HomeVideosPlaylistsResourcesArticlesAbout
Youtube logoSubscribe

Next.js App Router: Server Actions, Forms & Advanced Patterns (2025)

November 5, 2025

Next.js App Router has evolved significantly since its introduction. With Next.js 15 and 16, we now have Server Actions, the Form component, advanced caching APIs, and Partial Prerendering. This comprehensive guide covers modern patterns for building production-ready full-stack applications.

Core Architecture: Server and Client Components

Server Components (Default)

Server Components run on the server, enabling direct database access, file system operations, and optimized performance:

// app/dashboard/page.tsx
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'

export default async function DashboardPage() {
  const session = await auth()
  const stats = await db.user.getStats(session.user.id)

  return (
    <div>
      <h1>Welcome, {session.user.name}!</h1>
      <StatsGrid stats={stats} />
    </div>
  )
}

Client Components (When Needed)

Use 'use client' only for interactivity, browser APIs, or React hooks:

'use client'

import { useState, useTransition } from 'react'
import { refreshStats } from './actions'

function StatsGrid({ initialStats }: { initialStats: Stats }) {
  const [stats, setStats] = useState(initialStats)
  const [isPending, startTransition] = useTransition()

  const handleRefresh = () => {
    startTransition(async () => {
      const newStats = await refreshStats()
      setStats(newStats)
    })
  }

  return (
    <div>
      <button onClick={handleRefresh} disabled={isPending}>
        {isPending ? 'Refreshing...' : 'Refresh Stats'}
      </button>
      <StatsDisplay stats={stats} />
    </div>
  )
}

Server Actions: The Game Changer

Server Actions allow you to run server-side code directly from client components without API routes:

// app/actions.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  const post = await db.post.create({
    data: { title, content }
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}

export async function toggleLike(postId: string) {
  'use server'

  await db.like.toggle({ postId })
  revalidatePath(`/posts/${postId}`)
}

Using Server Actions in Components

// app/posts/new/page.tsx
import { createPost } from '../actions'
import Form from 'next/form'

export default function NewPostPage() {
  return (
    <Form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </Form>
  )
}

Progressive Enhancement with Forms

The new Form component provides automatic progressive enhancement:

import Form from 'next/form'

function CommentForm({ postId }: { postId: string }) {
  async function addComment(formData: FormData) {
    'use server'

    const content = formData.get('content') as string
    await db.comment.create({
      data: { postId, content }
    })

    revalidatePath(`/posts/${postId}`)
  }

  return (
    <Form action={addComment}>
      <input name="content" placeholder="Add a comment..." />
      <button type="submit">Comment</button>
    </Form>
  )
}

Advanced Caching and Data Management

Cache Control with cacheLife()

Next.js 16 introduces declarative cache control:

// app/api/market-data/route.ts
import { cacheLife } from 'next/cache'

export async function GET() {
  cacheLife('minutes') // Cache for 5 minutes

  const data = await fetchMarketData()
  return Response.json(data)
}

// Predefined cache profiles
cacheLife('seconds')  // 30 seconds
cacheLife('minutes')  // 5 minutes
cacheLife('hours')    // 1 hour
cacheLife('days')     // 1 day
cacheLife('weeks')    // 1 week
cacheLife('max')      // Maximum cache duration

Granular Cache Invalidation with cacheTag()

// app/actions.ts
'use server'

import { cacheTag } from 'next/cache'

export async function updateUserProfile(userId: string, data: UserData) {
  cacheTag(`user-${userId}`, 'user-profiles')

  await db.user.update(userId, data)

  // Invalidate specific caches
  revalidateTag(`user-${userId}`)
  revalidateTag('user-profiles')
}

Cache Management APIs

import {
  revalidatePath,
  revalidateTag,
  refresh
} from 'next/cache'

export async function publishPost(postId: string) {
  'use server'

  await db.post.update(postId, { published: true })

  // Revalidate the posts list
  revalidatePath('/posts')

  // Revalidate specific post cache
  revalidateTag(`post-${postId}`)

  // Refresh the entire page (client router)
  refresh()

  redirect('/posts')
}

Partial Prerendering (PPR)

Next.js 15 introduces Partial Prerendering for instant loading experiences:

// app/layout.tsx
import { unstable_noStore as noStore } from 'next/cache'

export default function RootLayout({ children }) {
  // Static shell renders immediately
  return (
    <html>
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  )
}

// app/dashboard/page.tsx
export default async function DashboardPage() {
  // This runs at build time for the static shell
  noStore() // Opt out of static generation for dynamic content

  const data = await fetchDynamicData()
  return <DashboardContent data={data} />
}

Advanced Routing Patterns

Parallel Routes for Complex Layouts

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  sidebar,
  modal
}: {
  children: React.ReactNode
  sidebar: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <div className="dashboard">
      {sidebar}
      <main>{children}</main>
      {modal}
    </div>
  )
}

// app/dashboard/@sidebar/page.tsx
export default function Sidebar() {
  return <nav>Dashboard Navigation</nav>
}

// app/dashboard/@modal/(..)login/page.tsx
export default function LoginModal() {
  return <LoginForm />
}

Intercepting Routes for Modals

// app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
  const post = await getPost(params.id)
  return <PostDetail post={post} />
}

// app/posts/[id]/edit/page.tsx (intercepted)
export default function EditPostModal({ params }) {
  return (
    <Modal>
      <EditPostForm postId={params.id} />
    </Modal>
  )
}

Metadata API Evolution

Next.js 16 enhances the Metadata API with better TypeScript support:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

interface Props {
  params: { slug: string }
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

Loading and Error States

Route Groups for Conditional Loading

// app/(marketing)/loading.tsx
export default function MarketingLoading() {
  return <MarketingSkeleton />
}

// app/(dashboard)/loading.tsx
export default function DashboardLoading() {
  return <DashboardSkeleton />
}

Global Error Boundaries

// app/error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="error-boundary">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Performance Optimization Patterns

Selective Hydration

// Only hydrate interactive parts
import dynamic from 'next/dynamic'

const InteractiveChart = dynamic(() => import('./InteractiveChart'), {
  ssr: false, // Disable SSR for this component
  loading: () => <ChartSkeleton />
})

Streaming with Selective Suspense

function PostPage({ params }) {
  return (
    <div>
      {/* Static content renders immediately */}
      <PostHeader />

      {/* Stream expensive content */}
      <Suspense fallback={<PostSkeleton />}>
        <PostContent id={params.id} />
      </Suspense>

      {/* Different loading state for comments */}
      <Suspense fallback={<CommentsSkeleton />}>
        <PostComments id={params.id} />
      </Suspense>
    </div>
  )
}

Turbopack 2.0 Integration

Next.js 15 uses Turbopack by default for faster development:

# Turbopack is now default
npm run dev

# Explicit Webpack (for compatibility)
npm run dev -- --webpack

# Production build with optimizations
npm run build

Best Practices for 2025

1. Embrace Server Actions Over API Routes

Before (API Routes):

// app/api/posts/route.ts
export async function POST(request: Request) {
  const body = await request.json()
  const post = await createPost(body)
  return Response.json(post)
}

// components/PostForm.tsx
'use client'
function PostForm() {
  const handleSubmit = async (data) => {
    await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(data)
    })
  }
}

After (Server Actions):

// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
  const post = await db.post.create(Object.fromEntries(formData))
  revalidatePath('/posts')
  redirect('/posts')
}

// components/PostForm.tsx
import { createPost } from '@/app/actions'
import Form from 'next/form'

function PostForm() {
  return (
    <Form action={createPost}>
      <input name="title" />
      <textarea name="content" />
      <button type="submit">Create</button>
    </Form>
  )
}

2. Use Declarative Caching

// app/api/analytics/route.ts
import { cacheLife } from 'next/cache'

export async function GET() {
  cacheLife('hours') // Clear intent

  const analytics = await getAnalytics()
  return Response.json(analytics)
}

3. Optimize with Partial Prerendering

// app/layout.tsx - Static shell
export default function Layout({ children }) {
  return (
    <html>
      <body>
        <StaticHeader />
        <main>{children}</main>
        <StaticFooter />
      </body>
    </html>
  )
}

// app/dashboard/page.tsx - Dynamic content
export default async function Dashboard() {
  unstable_noStore() // Opt out of static generation

  const data = await fetchRealtimeData()
  return <DynamicDashboard data={data} />
}

4. Progressive Enhancement Strategy

// Forms work without JavaScript
function CommentForm() {
  return (
    <Form action={addComment}>
      <input name="content" required />
      <button type="submit">Submit</button>
    </Form>
  )
}

// Enhanced with JavaScript
'use client'
function EnhancedCommentForm() {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment) => [...state, newComment]
  )

  return (
    <div>
      <Form action={addComment}>
        <input
          name="content"
          onChange={(e) => {
            addOptimisticComment({
              content: e.target.value,
              pending: true
            })
          }}
        />
        <button type="submit">Submit</button>
      </Form>

      <CommentsList comments={optimisticComments} />
    </div>
  )
}

5. Advanced Error Handling

// app/error.tsx
'use client'

export default function ErrorBoundary({
  error,
  reset
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  // Log to error reporting service
  useEffect(() => {
    reportError(error)
  }, [error])

  if (error.digest?.startsWith('NEXT_REDIRECT')) {
    return <RedirectError />
  }

  return (
    <div className="error-fallback">
      <h2>Application Error</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try Again</button>
      <ErrorDetails error={error} />
    </div>
  )
}

Migration from Pages Router

1. Convert API Routes to Server Actions

// Before: pages/api/posts.ts
export default async function handler(req, res) {
  if (req.method === 'POST') {
    const post = await createPost(req.body)
    res.status(201).json(post)
  }
}

// After: app/actions.ts
'use server'
export async function createPost(formData: FormData) {
  const post = await db.post.create(Object.fromEntries(formData))
  revalidatePath('/posts')
}

2. Update getServerSideProps to Server Components

// Before: pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
  const post = await getPost(params.id)
  return { props: { post } }
}

// After: app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
  const post = await getPost(params.id)
  return <PostDetail post={post} />
}

Conclusion

Next.js App Router has matured into a powerful full-stack framework. Server Actions eliminate API boilerplate, the Form component provides progressive enhancement, and advanced caching gives you fine-grained control over performance.

The key to mastering Next.js 15/16 is embracing server-first development while progressively enhancing with client-side features. Start migrating your API routes to Server Actions, implement declarative caching, and leverage Partial Prerendering for instant user experiences.

The future of React development is server-centric, and Next.js leads the way. What Server Action pattern are you most excited to implement?

TopFrontDev

Front-end techniques that actually ship. Practical engineering you can use in production.

Content

  • Videos
  • Playlists
  • Articles
  • Resources

About

  • About
  • Sponsorship
  • Contact

Connect

Youtube logogithub logolinkedin logo

© 2026 TopFrontDev. All rights reserved.