Blogs

Welcome to my blog domain where I share personal stories about things I've learned, projects I'm hacking on and just general findings. I also write for other publications.

Blog Banner

Production-Grade Error Handling in Node.js & Express

A beginner's journey from duplicated try-catch blocks to centralized error middleware


The Problem {#the-problem}

When I started building Express backends, I was confused about:

  • ❓ Where to handle errors - controllers or middleware?
  • ❓ How to send proper HTTP status codes (400, 401, 404, 500)?
  • ❓ How to avoid repeating try-catch everywhere?
  • ❓ What is error middleware and why do I need it?

Phase 1: Inline Error Responses {#phase-1}

What I Initially Wrote

// ❌ PHASE 1: No error handling for async operations
export const register = async (req, res) => {
  const { email, password } = req.body;

  if (!email) {
    res.status(400).json({ error: "Email required" });
    return;
  }

  if (!password) {
    res.status(400).json({ error: "Password required" });
    return;
  }

  // ⚠️ This WILL crash if database is down!
  const user = await prisma.user.create({ data: { email, password } });
  res.status(201).json({ user });
};

Problems

  • ❌ No error handling for async operations
  • ❌ Unhandled promise rejections crash the app
  • ❌ Inconsistent error formats
  • ❌ Validation mixed with business logic

Lesson Learned: We need try-catch for async operations!


Phase 2: Adding Try-Catch {#phase-2}

The "Fix"

// ✅ Better: Won't crash, but duplicated everywhere
export const register = async (req, res) => {
  try {
    const { email, password } = req.body;

    if (!email) {
      res.status(400).json({ error: "Email required" });
      return;
    }

    const user = await prisma.user.create({ data: { email, password } });
    res.status(201).json({ user });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: "Registration failed" });
  }
};

// Repeat this pattern 50+ times... 😫

New Problems

  • ❌ Massive code duplication (same try-catch in 50+ functions)
  • ❌ Inconsistent error messages
  • ❌ All errors become 500, even validation errors
  • ❌ Lose context about what actually failed

Lesson Learned: We need to distinguish between error types!


Phase 3: Throwing Errors {#phase-3}

Throw Instead of Return

// 🔄 BETTER: Throw errors, catch handles responses
export const register = async (req, res) => {
  try {
    if (!email) throw new Error("Email required");
    if (!password) throw new Error("Password required");

    const user = await prisma.user.create({ data: { email, password } });
    res.status(201).json({ user });
  } catch (error) {
    console.error(error);
    res.status(400).json({ error: error.message });
  }
};

Still Have Problems

  • ❌ All errors get same status code (400)
  • ❌ Catch block repeated everywhere
  • ❌ Can't differentiate validation vs auth vs not found errors

Lesson Learned: We need custom error classes with status codes!


Phase 4: Custom Error Classes {#phase-4}

Create Error Classes

// src/utils/errors.ts
class AppError extends Error {
  statusCode: number;

  constructor(statusCode: number, message: string) {
    super(message);
    this.statusCode = statusCode;
  }
}

class ValidationError extends AppError {
  constructor(message: string) {
    super(400, message); // Always 400
  }
}

class UnauthorizedError extends AppError {
  constructor(message: string) {
    super(401, message); // Always 401
  }
}

class NotFoundError extends AppError {
  constructor(message: string) {
    super(404, message); // Always 404
  }
}

class ConflictError extends AppError {
  constructor(message: string) {
    super(409, message); // Always 409
  }
}

Use in Controllers

// 🔄 BETTER: Different error types with correct status codes
export const register = async (req, res) => {
  try {
    if (!email) throw new ValidationError("Email required"); // 400

    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) throw new ConflictError("Email already registered"); // 409

    const user = await prisma.user.create({ data: { email, password } });
    res.status(201).json({ user });
  } catch (error) {
    console.error(error);
    if (error instanceof AppError) {
      res.status(error.statusCode).json({ error: error.message });
    } else {
      res.status(500).json({ error: "Server error" });
    }
  }
};

Remaining Problem

  • ❌ Still duplicating catch block in every controller

Lesson Learned: We need to centralize error handling!


Phase 5: Error Middleware {#phase-5}

Understanding Middleware

Middleware is a function Express calls in sequence:

Request → Middleware 1 → Middleware 2 → Controller → Response

The Magic: Express identifies error handlers by counting parameters!

// Normal middleware (3 parameters)
app.use((req, res, next) => {
  console.log("Normal middleware");
  next();
});

// Error middleware (4 parameters) ← The magic!
app.use((err, req, res, next) => {
  // ^^^ Extra parameter makes it an error handler!
  console.log("Error middleware");
  res.status(500).json({ error: err.message });
});

When you call next(error), Express skips all normal middleware and jumps to the first function with 4 parameters!

Create Error Middleware

// src/middlewares/error.middleware.ts
export const errorHandler = (
  err: Error,      // ← 1st parameter
  req: Request,    // ← 2nd parameter
  res: Response,   // ← 3rd parameter
  next: NextFunction // ← 4th parameter (makes it error handler!)
): void => {
  console.error("Error:", err);

  // Check if it's our custom error
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
    return;
  }

  // Unknown error - default to 500
  res.status(500).json({
    success: false,
    message: "Internal server error",
  });
};

Register in Server (MUST BE LAST!)

// src/server.ts
const app = express();

// Normal middleware
app.use(express.json());

// Routes
app.use("/api/auth", authRoutes);

// ✅ Error handler MUST be registered LAST!
app.use(errorHandler);

app.listen(4000);

Update Controllers

// ✅ MUCH BETTER: Pass errors to middleware
export const register = async (req, res, next) => {
  try {
    if (!email) throw new ValidationError("Email required");

    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) throw new ConflictError("Email already registered");

    const user = await prisma.user.create({ data: { email, password } });
    res.status(201).json({ user });
  } catch (error) {
    next(error); // ← Pass to middleware!
  }
};

Visual Flow

Controller throws error
         ↓
Catch block: next(error)
         ↓
Express skips normal middleware
         ↓
Error middleware runs
         ↓
Sends response to client

Remaining Problem

  • ❌ Still have try-catch in every controller

Lesson Learned: Can we eliminate try-catch entirely?


Phase 6: AsyncHandler - The Final Solution! {#phase-6}

The Problem

// Async function returns a Promise
export const register = async (req, res) => {
  throw new Error("Oops"); // Creates rejected Promise
};

// Express doesn't catch Promise rejections automatically!
// Result: Unhandled Promise Rejection 💥

The Solution: AsyncHandler Wrapper

// src/utils/asyncHandler.ts
export const asyncHandler = (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

How It Works

Think of it as automatic try-catch:

// Without asyncHandler - manual try-catch
export const register = async (req, res, next) => {
  try {
    const user = await prisma.user.create({ data: req.body });
    res.json({ user });
  } catch (error) {
    next(error);
  }
};

// With asyncHandler - automatic try-catch
export const register = asyncHandler(async (req, res) => {
  const user = await prisma.user.create({ data: req.body });
  res.json({ user });
  // asyncHandler automatically does .catch(next) for us!
});

Final Controllers - Clean & Beautiful!

// ✅ PERFECT: No try-catch needed!
export const register = asyncHandler(async (req: Request, res: Response) => {
  const { email, password } = req.body;

  if (!email) throw new ValidationError("Email required");

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) throw new ConflictError("Email already registered");

  const user = await prisma.user.create({ data: { email, password } });

  res.status(201).json({
    success: true,
    data: { user },
  });
});

export const login = asyncHandler(async (req: Request, res: Response) => {
  const { email, password } = req.body;

  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) throw new UnauthorizedError("Invalid credentials");

  res.json({
    success: true,
    data: { user },
  });
});

Common Pitfalls {#pitfalls}

Pitfall 1: Wrong Number of Parameters

// ❌ WRONG: Only 2 parameters
app.use((err, res) => {
  res.status(500).json({ error: err.message });
});

// ✅ CORRECT: MUST have 4 parameters
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

Pitfall 2: Error Handler in Wrong Position

// ❌ WRONG: Before routes
app.use(errorHandler);
app.use("/api/auth", authRoutes);

// ✅ CORRECT: After routes
app.use("/api/auth", authRoutes);
app.use(errorHandler);

Pitfall 3: Forgetting next() Parameter

// ❌ WRONG: No next parameter
export const register = async (req, res) => {
  try {
    // ...
  } catch (error) {
    // Can't pass to middleware!
  }
};

// ✅ CORRECT: Include next
export const register = async (req, res, next) => {
  try {
    // ...
  } catch (error) {
    next(error);
  }
};

Complete Example {#complete}

Project Structure

src/
├── controllers/
│   └── auth.controller.ts
├── middlewares/
│   └── error.middleware.ts
├── routes/
│   └── auth.route.ts
├── utils/
│   ├── errors.ts
│   └── asyncHandler.ts
└── server.ts

1. Error Classes

// src/utils/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string
  ) {
    super(message);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) { super(400, message); }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = "Unauthorized") { super(401, message); }
}

export class NotFoundError extends AppError {
  constructor(message: string = "Not found") { super(404, message); }
}

export class ConflictError extends AppError {
  constructor(message: string = "Already exists") { super(409, message); }
}

2. AsyncHandler

// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from "express";

export const asyncHandler = (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

3. Error Middleware

// src/middlewares/error.middleware.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/errors.js";

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  console.error("Error:", err);

  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
    return;
  }

  res.status(500).json({
    success: false,
    message: "Internal server error",
  });
};

4. Controller

// src/controllers/auth.controller.ts
import { Request, Response } from "express";
import { asyncHandler } from "../utils/asyncHandler.js";
import { ValidationError, UnauthorizedError, ConflictError } from "../utils/errors.js";
import { prisma } from "../lib/prisma.js";

export const register = asyncHandler(async (req: Request, res: Response) => {
  const { email, password, name } = req.body;

  if (!email) throw new ValidationError("Email required");
  if (!password) throw new ValidationError("Password required");

  const existingUser = await prisma.user.findUnique({ where: { email } });
  if (existingUser) throw new ConflictError("Email already registered");

  const user = await prisma.user.create({
    data: { email, password, name },
    select: { id: true, email: true, name: true },
  });

  res.status(201).json({
    success: true,
    data: { user },
  });
});

export const login = asyncHandler(async (req: Request, res: Response) => {
  const { email, password } = req.body;

  if (!email) throw new ValidationError("Email required");
  if (!password) throw new ValidationError("Password required");

  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) throw new UnauthorizedError("Invalid credentials");

  res.status(200).json({
    success: true,
    data: { user },
  });
});

5. Server Setup

// src/server.ts
import express from "express";
import { authRoutes } from "./routes/index.js";
import { errorHandler } from "./middlewares/error.middleware.js";

const app = express();
const PORT = 4000;

// Middleware
app.use(express.json());

// Routes
app.use("/api/auth", authRoutes);

// 404 Handler
app.use("*", (req, res) => {
  res.status(404).json({
    success: false,
    message: `Route ${req.originalUrl} not found`,
  });
});

// Error Handler (MUST BE LAST)
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`🚀 Server running on http://localhost:${PORT}`);
});

Summary: The Evolution

The Journey

Phase 1: Inline responses → App crashes
Phase 2: Try-catch everywhere → Duplicated 50+ times
Phase 3: Throw in try → Still duplicated catch
Phase 4: Custom errors → Better, but still duplicated
Phase 5: Error middleware → Centralized handling
Phase 6: AsyncHandler → Perfect! Clean & maintainable

Before vs After

// ❌ BEFORE: 15 lines per controller
export const register = async (req, res) => {
  try {
    if (!email) {
      res.status(400).json({ error: "Email required" });
      return;
    }
    const user = await prisma.user.create({ data: { email, password } });
    res.status(201).json({ user });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: "Registration failed" });
  }
};

// ✅ AFTER: 8 lines per controller
export const register = asyncHandler(async (req, res) => {
  if (!email) throw new ValidationError("Email required");
  const user = await prisma.user.create({ data: { email, password } });
  res.status(201).json({ user });
});

47% less code, infinitely more maintainable! 🎉

Key Takeaways

Custom error classes provide consistent status codes
Error middleware centralizes error handling logic
AsyncHandler eliminates repetitive try-catch blocks
Clean controllers focus only on business logic
Maintainable codebase with consistent error responses


Happy coding! 🚀

Dec 09, 2025

Blog Banner

AI Agentic Workflows

In the rapidly evolving landscape of artificial intelligence, agentic workflows empower developers to create sophisticated, autonomous systems that leverage large language models (LLMs) with enhanced capabilities like tool calling and task orchestration. These workflows provide structured approaches to break down complex tasks, route queries efficiently, parallelize operations, coordinate multi-step processes, and iteratively optimize outputs. This article explores five foundational agentic workflow patterns, complete with practical examples, to help developers design robust AI agents for real-world applications.


1. Prompt Chaining

When to Use:

  • Structured multi-step tasks
  • Where outputs of one step feed the next

Example: Generate a commit message and validate it

import { generateText } from "ai";

async function generateCommitMessage(diff) {
  const raw = await generateText({
    model: "gpt-4",
    prompt: `Write a conventional commit message for this diff:\n${diff}`,
  });

  const valid = /^(feat|fix|docs|chore)/.test(raw);

  if (!valid) {
    return await generateText({
      model: "gpt-4",
      prompt: `Fix this commit message to follow conventional commit format:\n${raw}`,
    });
  }

  return raw;
}

2. Routing

When to Use:

  • Tasks vary by type or domain
  • Specialized models or paths needed

Example: Classify and delegate user query

import { generateObject, generateText } from "ai";
import { z } from "zod";

const schema = z.object({
  type: z.enum(["code", "creative", "search"]),
  reason: z.string(),
});

const modelMap = {
  code: "claude",
  creative: "gpt-4",
  search: "gemini",
};

async function routeQuery(query) {
  const { type } = await generateObject({
    model: "gpt-4",
    schema,
    prompt: `Classify the query into code, creative, or search:\n${query}`,
  });

  return await generateText({ model: modelMap[type], prompt: query });
}

3. Parallelization

When to Use:

  • Multitasking subtasks simultaneously
  • Independent analysis by multiple agents

Example: Review code for security, performance, and maintainability

import { generateObject, generateText } from "ai";

async function reviewCode(code) {
  const tasks = [
    {
      label: "security",
      prompt: `List security issues in this code:\n${code}`,
    },
    { label: "performance", prompt: `Find performance bottlenecks:\n${code}` },
    {
      label: "maintainability",
      prompt: `Comment on maintainability:\n${code}`,
    },
  ];

  const reviews = await Promise.all(
    tasks.map((task) =>
      generateText({ model: "gpt-4", prompt: task.prompt }).then((result) => ({
        type: task.label,
        result,
      }))
    )
  );

  const summary = await generateText({
    model: "gpt-4",
    prompt: `Summarize the following review results:
${JSON.stringify(reviews)}`,
  });

  return summary;
}

4. Orchestrator

When to Use:

  • Controlled flow with dependency between steps
  • Multiple types of tasks need coordination

Example: Implementing a feature with task-specific calls

async function implementFeature(goal) {
  const plan = await generateObject({
    model: "openai/gpt-3.5",
    prompt: `Break down this feature into file tasks with change type:
${goal}`,
  });

  const fileChanges = await Promise.all(
    plan.files.map((file) => {
      const actionPrompt =
        file.changeType === "create"
          ? `Create a new file ${file.path} with purpose: ${file.purpose}`
          : file.changeType === "modify"
          ? `Improve existing file ${file.path}`
          : `Remove unused code in ${file.path}`;

      return generateText({ model: "gpt-4", prompt: actionPrompt });
    })
  );

  return fileChanges;
}

5. Evaluator + Optimizer

When to Use:

  • Iterative quality improvement
  • Evaluation-driven refinement

Example: Improve code comments until quality is acceptable

async function improveComment(code) {
  let comment = await generateText({
    model: "gpt-4",
    prompt: `Write a concise helpful comment for:
${code}`,
  });

  for (let attempt = 0; attempt < 3; attempt++) {
    const evalResult = await generateObject({
      model: "gpt-4",
      schema: z.object({ clarity: z.number(), issues: z.string() }),
      prompt: `Evaluate this comment: ${comment}`,
    });

    if (evalResult.clarity >= 8) break;

    comment = await generateText({
      model: "gpt-4",
      prompt: `Improve this comment based on issues:
${evalResult.issues}`,
    });
  }

  return comment;
}

Summary

| Workflow Type | Use Case | | --------------- | ------------------------------- | | Prompt Chaining | Structured, multi-step tasks | | Routing | Handle diverse task types | | Parallelization | Speed up subtasks | | Orchestrator | Fine-grain control, multi-agent | | Evaluator+Opt. | Iterative improvement |

Together, these patterns form the building blocks for robust, smart, autonomous agents. Agents are basically llms with added functionalities (tools calling) ˚ i wanna paste it to my medium articke

Jul 01, 2025

Blog Banner

React vs Next.js — What Actually Changes? A Beginner-Friendly Deep Dive

React is one of the most popular libraries for building modern web apps. If you’ve learned React, you’re already on the right track. But as your app grows, you may notice problems:

  • Slow initial load times
  • Poor SEO performance
  • Excessive boilerplate for data fetching
  • Growing JavaScript bundle sizes

Next.js 15 addresses these issues by combining the best of React with powerful features like server-side rendering, built-in routing, and more. Let’s explore how React and Next.js 15 differ, step by step.

What Happens When You Visit a React App?

When a user visits a React app (e.g., built with create-react-app or Vite), here’s what happens:

  1. Browser Sends a Request
    The browser requests the URL (e.g., GET https://your-react-app.com/). The server responds with a minimal HTML file:

    <html>
      <head>
        <title>Your App</title>
      </head>
      <body>
        <div id="root"></div>
        <script src="/main.js"></script>
      </body>
    </html>
    

    This HTML contains an empty <div id="root"> and a script tag for the JavaScript bundle.

  2. JavaScript Bundle Execution

    • The browser downloads the entire React app’s JavaScript code.
    • React “hydrates” the empty <div id="root">, rendering the app in the browser.
    • Data fetching (e.g., via useEffect or axios) happens after the initial render, causing re-renders once data is available.

Example: E-commerce Landing Page in React

Consider a landing page with a header, navigation, and a list of featured products fetched from an API:

import { useState, useEffect } from 'react';
import axios from 'axios';

function LandingPage() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    axios.get('/api/featured-products')
      .then((res) => {
        setProducts(res.data);
        setLoading(false);
      });
  }, []);

  return (
    <div>
      <header>Shop Now!</header>
      <nav>Home | Products | Cart</nav>
      <section>
        <h1>Welcome to Our Store</h1>
        {loading ? (
          <p>Loading...</p>
        ) : (
          <div>
            <h2>Featured Products</h2>
            <ul>
              {products.map(product => (
                <li key={product.id}>{product.name}</li>
              ))}
            </ul>
          </div>
        )}
      </section>
    </div>
  );
}

Outcome:

  • The user sees a blank page or loading spinner until the JavaScript bundle downloads and the API call completes.
  • Search engines see only <div id="root">, harming SEO.
  • The entire app, including static components, is bundled into JavaScript, increasing load times.

Problems with Plain React (Client-Side Rendering)

  1. Poor SEO
    Search engine crawlers see an empty <div id="root"> with no meaningful content, leading to poor indexing and rankings.

  2. Slow First Load
    Users wait for the JavaScript bundle to download and execute before seeing content, resulting in a blank screen or spinner.

  3. Delayed Data Fetching
    Data is fetched client-side after the component mounts, causing additional renders and delays.

  4. Large JavaScript Bundles
    All components, even static ones, are included in the JavaScript bundle, increasing its size and slowing down the app.

How Next.js 15 Solves These Problems

Next.js 15 enhances React with features like server-side rendering (SSR), React Server Components, and the App Router. Here’s how it works:

Example: E-commerce Landing Page in Next.js 15

Using Next.js 15’s App Router and React Server Components, the same landing page is handled differently:

// app/page.tsx
import { db } from "@/lib/db";

export default async function LandingPage() {
  const products = await db.getFeaturedProducts(); // Fetches on the server

  return (
    <div>
      <header>Shop Now!</header>
      <nav>Home | Products | Cart</nav>
      <section>
        <h1>Welcome to Our Store</h1>
        <div>
          <h2>Featured Products</h2>
          <ul>
            {products.map(product => (
              <li key={product.id}>{product.name} - ${product.price}</li>
            ))}
          </ul>
        </div>
      </section>
    </div>
  );
}

What Happens:

  1. The Next.js server fetches the data (e.g., featured products) and renders the component into HTML.
  2. The browser receives a fully rendered HTML page, including product data, which displays instantly.
  3. Search engines can index the content, improving SEO.

Adding Interactivity: For client-side interactivity (e.g., an “Add to Cart” button), use a client component:

// app/components/AddToCartButton.tsx
'use client';
import { useState } from 'react';

export function AddToCartButton({ productId }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => setAdded(!added)}>
      {added ? 'Added!' : 'Add to Cart'}
    </button>
  );
}

Integrate it into the server component:

// app/page.tsx (updated)
import { db } from "@/lib/db";
import { AddToCartButton } from "@/components/AddToCartButton";

export default async function LandingPage() {
  const products = await db.getFeaturedProducts();

  return (
    <div>
      <header>Shop Now!</header>
      <nav>Home | Products | Cart</nav>
      <section>
        <h1>Welcome to Our Store</h1>
        <div>
          <h2>Featured Products</h2>
          <ul>
            {products.map(product => (
              <li key={product.id}>
                {product.name} - ${product.price}
                <AddToCartButton productId={product.id} />
              </li>
            ))}
          </ul>
        </div>
      </section>
    </div>
  );
}

Outcome:

  • The page loads instantly with pre-rendered content.
  • Only interactive components (e.g., AddToCartButton) are included in the JavaScript bundle, reducing its size.
  • SEO is improved because the HTML contains all content.

Extra Features in Next.js 15

  1. Built-in API Routes
    Create backend endpoints without a separate server:

    // app/api/contact/route.ts
    export async function POST(req) {
      const data = await req.json();
      await db.save(data);
      return Response.json({ status: 'ok' });
    }
    
  2. Server Actions
    Handle form submissions without API routes:

    // app/actions.ts
    'use server';
    export async function handleSubmit(data) {
      await db.save(data);
    }
    
  3. App Router & File-Based Navigation
    Define routes using the file system:

    app/
    ├─ layout.tsx        # Shared layout
    ├─ page.tsx          # /
    ├─ about/
    │  └─ page.tsx       # /about
    ├─ posts/
    │  └─ page.tsx       # /posts
    ├─ api/
    │  └─ hello/
    │     └─ route.ts    # /api/hello
    

Summary: React vs Next.js 15

| Feature | React | Next.js 15 | |---------------------|---------------------------|--------------------------------| | Initial HTML | Blank <div> | Fully rendered content | | SEO | Poor | Excellent | | First Load | Slow | Fast | | Data Fetching | Client-side only | Server-side by default | | Routing | Manual (React Router) | File-based | | Bundle Size | Large | Smaller (server components) | | API Support | Needs backend | Built-in | | Server Logic | Not available | Server Actions supported |

Final Thoughts

React is a powerful frontend library, but it requires manual handling of routing, SEO, and performance. Next.js 15 builds on React, offering:

  • Better performance with server-side rendering
  • SEO out of the box
  • Simplified data fetching
  • Built-in backend capabilities
  • Smaller JavaScript bundles
  • Clean architecture with the App Router

If you know React, Next.js 15 is the logical next step to build fast, scalable, and SEO-friendly web apps.

Jun 05, 2025

Start Call