Skip to content

SSR API Routes — Building API Endpoints Within SSR Frameworks

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about SSR API Routes. We cover key concepts, practical examples, and best practices to help you master this topic.

SSR API routes allow building server-side API endpoints alongside SSR pages in frameworks like Next.js, providing data to both the SSR rendering pipeline and client-side JavaScript.

What You'll Learn

By the end of this tutorial, you will understand how SSR API routes work, how to create API routes in Next.js Pages Router and App Router, how to handle CRUD operations, how to secure API routes with authentication and validation, and how to use API routes for both SSR data fetching and client-side requests.

Why It Matters

In SSR applications, you need server-side endpoints for two purposes: fetching data during SSR rendering and providing data to client-side JavaScript after hydration. API routes unify these into a single interface, keeping your data logic in one place.

Real-World Use

A Next.js e-commerce site uses API routes for both SSR (product page fetches data via getServerSideProps calling the API route) and client-side interactions (adding to cart, searching, filtering). The API route handles authorization, validation, and data formatting in one place.

SSR API Route Architecture
    ┌──────────────────────────────────────────────────────────┐
    │            SSR + API Route Architecture                  │
    ├──────────────────────────────────────────────────────────┤
    │                                                          │
    │  Server Request                                          │
    │    │                                                     │
    │    ├──> SSR Route (pages/product/[id].js)               │
    │    │      │                                              │
    │    │      └──> API Route (pages/api/products/[id].js)   │
    │    │             │                                       │
    │    │             └──> Database / External API           │
    │    │                                                     │
    │    ├──> Static assets (images, CSS)                     │
    │    │                                                     │
    │   Browser Request (client-side interaction)              │
    │    │                                                     │
    │    └──> fetch('/api/products/[id]')                     │
    │           │                                              │
    │           └──> Same API Route as SSR                     │
    │                  │                                       │
    │                  └──> Same Database / External API      │
    │                                                          │
    │  Benefits:                                               │
    │  • Unified data layer (SSR and client use same API)     │
    │  • Authentication handled once                           │
    │  • Validation logic centralized                          │
    │  • Caching configured per endpoint                       │
    │                                                          │
    └──────────────────────────────────────────────────────────┘

Think of SSR API routes like a restaurant where the same kitchen prepares food for both dine-in (SSR) and takeout (client API). The recipes (data logic) and ingredients (database) are the same. The only difference is how the food is packaged (wrapped in HTML for SSR, served as JSON for API).

Next.js API Routes

// pages/api/products/index.js — Product listing API
export default async function handler(req, res) {
    switch (req.method) {
        case 'GET':
            return getProducts(req, res);
        case 'POST':
            return createProduct(req, res);
        default:
            res.setHeader('Allow', ['GET', 'POST']);
            return res.status(405).end(`Method ${req.method} Not Allowed`);
    }
}

async function getProducts(req, res) {
    const { page = 1, limit = 10, category } = req.query;

    try {
        const products = await db.products.findAll({
            page: parseInt(page),
            limit: parseInt(limit),
            category
        });

        // Cache for 60 seconds at the CDN level
        res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=30');
        res.status(200).json(products);
    } catch (error) {
        console.error('Failed to fetch products:', error);
        res.status(500).json({ error: 'Failed to fetch products' });
    }
}

async function createProduct(req, res) {
    const { name, price, category, description } = req.body;

    // Validation
    const errors = [];
    if (!name || name.length < 2) errors.push('Name is required (min 2 chars)');
    if (!price || isNaN(price) || price <= 0) errors.push('Valid price is required');

    if (errors.length > 0) {
        return res.status(422).json({ errors });
    }

    try {
        const product = await db.products.create({
            name, price, category, description
        });
        res.status(201).json(product);
    } catch (error) {
        console.error('Failed to create product:', error);
        res.status(500).json({ error: 'Failed to create product' });
    }
}

// Using API route in getServerSideProps
export async function getServerSideProps() {
    // Call the API route internally (or use the same data function)
    const products = await db.products.findAll({ limit: 20 });

    return {
        props: { products }
    };
}

App Router API Routes

// app/api/products/route.js — Next.js App Router API route
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function GET(request) {
    const { searchParams } = new URL(request.url);
    const page = parseInt(searchParams.get('page')) || 1;
    const limit = parseInt(searchParams.get('limit')) || 10;

    try {
        const products = await db.products.findAll({ page, limit });

        return NextResponse.json(products, {
            status: 200,
            headers: {
                'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30'
            }
        });
    } catch (error) {
        return NextResponse.json(
            { error: 'Failed to fetch products' },
            { status: 500 }
        );
    }
}

export async function POST(request) {
    const body = await request.json();
    const { name, price } = body;

    // Validate
    if (!name || typeof name !== 'string') {
        return NextResponse.json(
            { error: 'Name is required' },
            { status: 422 }
        );
    }

    try {
        const product = await db.products.create(body);
        return NextResponse.json(product, { status: 201 });
    } catch (error) {
        return NextResponse.json(
            { error: 'Failed to create product' },
            { status: 500 }
        );
    }
}

// Using in a Server Component
// app/products/page.js
async function ProductsPage() {
    // Fetch directly (not through HTTP)
    const products = await db.products.findAll({ limit: 20 });

    return (
        <div>
            <h1>Products</h1>
            {products.map(product => (
                <ProductCard key={product.id} product={product} />
            ))}
        </div>
    );
}

Securing API Routes

// middleware.js — Apply middleware to API routes
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request) {
    // Protect API routes
    if (request.nextUrl.pathname.startsWith('/api/admin')) {
        const token = await getToken({ req: request });

        if (!token) {
            return NextResponse.json(
                { error: 'Authentication required' },
                { status: 401 }
            );
        }

        if (token.role !== 'admin') {
            return NextResponse.json(
                { error: 'Admin access required' },
                { status: 403 }
            );
        }
    }

    // Rate limiting (simplified)
    const ip = request.ip || request.headers.get('x-forwarded-for');
    if (ip) {
        // Check rate limit in Redis
        // ...
    }

    return NextResponse.next();
}

export const config = {
    matcher: '/api/:path*'
};

// API route with authentication
import { getServerSession } from 'next-auth';

export async function GET(request) {
    const session = await getServerSession();

    if (!session) {
        return NextResponse.json(
            { error: 'Not authenticated' },
            { status: 401 }
        );
    }

    // Fetch user-specific data
    const data = await db.getUserData(session.user.id);
    return NextResponse.json(data);
}

Common Mistakes

  1. Not handling HTTP methods correctly. API functions must handle GET, POST, PUT, DELETE separately. Return 405 Method Not Allowed for unsupported methods.
  2. No input validation. Never trust user input in API routes. Validate all fields, sanitize strings, and check types before processing.
  3. Exposing internal errors. Never return raw database errors, stack traces, or internal server details in API responses. Log them server-side and return generic error messages.
  4. No authentication on API routes. API routes are publicly accessible by default. Protect authenticated routes with session or token checks.
  5. Mixing SSR and API concerns. SSR page routes (pages/product.js) should not contain API logic. Keep API logic in pages/api/ routes for Separation Of Concerns.

Practice Questions

  1. How do API routes differ from SSR page routes in Next.js?
  2. How do you handle different HTTP methods in an API route?
  3. How do you secure an API route with authentication?
  4. What is the purpose of Cache-Control headers on API routes?
  5. How do you validate input in API routes?

Challenge: Build a complete CRUD API for a blog in Next.js: GET /api/posts (list), GET /api/posts/[id] (single), POST /api/posts (create), PUT /api/posts/[id] (update), DELETE /api/posts/[id] (delete). Include input validation, authentication middleware, proper HTTP status codes, error handling, and cache headers. Use the same data fetching function in both SSR pages and API routes.

FAQ

Should I call API routes from getServerSideProps?

You can, but it is better to share the same data fetching function. Calling the API route from SSR adds unnecessary HTTP overhead. Use a shared data layer.

How do I handle file uploads in API routes?

Use formidable or multer middleware to parse multipart/form-data. The API route receives the file buffer, saves it to storage (S3, local), and returns the URL.

Can I use ORMs in Next.js API routes?

Yes. You can use Prisma, Drizzle, TypeORM, or any database client in API routes. The API route runs on the server and has full access to the database.

How do I test API routes?

Use testing libraries like Supertest or Vitest with Next.js API route helpers. Test each HTTP method, validation errors, authentication, and edge cases.

Do API routes support WebSockets?

Next.js API routes are HTTP-only. For WebSockets, use a separate server (Socket.io) or a custom server implementation.

Mini Project

Build a full REST API for a blog within a Next.js application: API routes for posts, comments, and categories. All routes have input validation, authentication checks, proper error handling, and cache headers. The SSR pages use the same data functions as the API routes. Include a client-side JavaScript component that fetches from the API routes after hydration.

What's Next

You understand SSR API routes. Now explore SSR Middleware to handle authentication, logging, and request processing.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro