← Back to Blog

Building Scalable Next.js Applications

By Sabbir AI

I deployed my first Next.js app to production 2 years ago. It was a simple marketing site with maybe 5 pages. Clean, fast, beautiful. Then the company grew, we added features, the codebase ballooned, and suddenly our build times went from 30 seconds to 8 minutes. Our homepage bundle was 800KB. Everything was slow and painful.

That's when I learned the hard truth: Next.js makes it easy to build apps fast, but building them to SCALE requires actual planning and architecture. You can't just keep adding features to the pages directory and hope for the best.

So I refactored everything. Multiple times. Learned what works and what absolutely doesn't. And now I'm going to save you from making the same mistakes I did. Buckle up.

Project Structure (This Matters More Than You Think)

Most Next.js tutorials show you a structure like this:

/pages
/components
/styles
/utils

This works great until you have 50 components and you're scrolling through a folder trying to find the right Button variant. Or you've got 30 utility functions with no organization. It becomes chaos fast.

Here's what actually scales—a feature-based structure:

/features
  /auth
    /components
      LoginForm.tsx
      SignupModal.tsx
    /hooks
      useAuth.ts
      useSession.ts
    /utils
      validation.ts
    /api
      login.ts
  /dashboard
    /components
    /hooks
    /utils
/shared
  /components
    /ui
      Button.tsx
      Input.tsx
    /layout
      Header.tsx
      Footer.tsx
  /hooks
    useMediaQuery.ts
  /utils
    formatting.ts
/pages
/styles

Why is this better? Because when you're working on the auth feature, everything you need is RIGHT THERE. You're not jumping between folders constantly. You can find things by context, not by type.

The /shared folder is for stuff used across multiple features. Button component? Shared. Auth-specific LoginButton with custom logic? Lives in the auth feature.

I fought this structure at first because it felt like "too many folders." Then we had 3 people working on different features simultaneously without merge conflicts every 5 minutes. Suddenly the folder structure felt genius.

Code Splitting (Stop Shipping Everything to Everyone)

Here's a mistake I see constantly: people import heavy libraries at the top of their files without thinking about it.

import { Chart } from 'chart.js'; // 200KB
import { Editor } from 'rich-text-editor'; // 150KB

export default function Dashboard() {
  // Maybe uses Chart, maybe doesn't
}

Congrats, you just added 350KB to your initial bundle that loads on EVERY page. Even the landing page that doesn't use charts at all.

Next.js gives you dynamic imports for exactly this reason:

import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./components/Chart'), {
  loading: () => 

Loading chart...

, ssr: false // if the component doesn't need to render server-side }); export default function Dashboard() { return (

Dashboard

); }

Now the Chart component only loads when someone actually visits the Dashboard page. Your landing page stays lean and fast.

I used this approach to reduce our main bundle from 800KB to 200KB. The difference in load time was night and day. Users noticed. Google's PageSpeed Insights score went from 45 to 92. My boss thought I was a wizard.

Pro tip: Use dynamic imports for: modals, charts/visualizations, rich text editors, admin-only features, and anything above 50KB.

State Management (Without Losing Your Mind)

State management in Next.js is weirdly controversial. Some people swear by Redux. Others say "just use React Context." Others reach for Zustand or Jotai. Who's right?

Honestly? It depends on your app's complexity. But here's my hot take after building 5+ large Next.js apps: for MOST apps, you don't need Redux.

Here's my state management hierarchy:

Level 1 - Component state (useState): For simple UI state. Is the modal open? What's the current tab? Use useState. Don't overthink it.

Level 2 - URL state (useSearchParams, router): For state that should be shareable/bookmarkable. Filters, pagination, search queries. Store it in the URL. Free persistence, free shareable links.

// Instead of this:
const [page, setPage] = useState(1);

// Do this:
const searchParams = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');

// Update URL:
router.push(`?page=${newPage}`);

Level 3 - React Context: For app-wide state that doesn't change often. Theme settings, user info, feature flags. Context works fine for this.

Level 4 - Zustand/Jotai: For complex client-side state that updates frequently. Shopping carts, real-time collaboration, complex forms. These libraries are lightweight and don't require boilerplate like Redux.

I replaced Redux with Zustand in a project and deleted 400 lines of boilerplate. The store went from multiple files with actions, reducers, and types to a single 50-line file:

import create from 'zustand';

const useStore = create((set) => ({
  cart: [],
  addItem: (item) => set((state) => ({ 
    cart: [...state.cart, item] 
  })),
  removeItem: (id) => set((state) => ({ 
    cart: state.cart.filter(i => i.id !== id) 
  })),
}));

That's it. No actions, no reducers, no dispatch. Just a store you can use anywhere.

API Routes Best Practices (Learn From My Pain)

Next.js API routes are convenient, but they can become a mess fast if you're not careful. Here's what I learned:

1. Use middleware for common logic. Don't copy-paste authentication checks into every route.

// middleware/auth.ts
export function withAuth(handler) {
  return async (req, res) => {
    const session = await getSession(req);
    if (!session) {
      return res.status(401).json({ error: 'Unauthorized' });
    }
    req.user = session.user;
    return handler(req, res);
  };
}

// pages/api/protected-route.ts
export default withAuth(async (req, res) => {
  // req.user is available here
  res.json({ data: 'secret stuff' });
});

2. Rate limiting is NOT optional. I learned this when someone hit our API 10,000 times in 2 minutes and our Vercel bill jumped $50. Add rate limiting:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per window
});

3. Proper error handling. Return consistent error formats. Your frontend will thank you.

try {
  const data = await fetchData();
  res.status(200).json({ success: true, data });
} catch (error) {
  console.error('API Error:', error);
  res.status(500).json({ 
    success: false, 
    error: 'Internal server error',
    message: process.env.NODE_ENV === 'development' ? error.message : undefined
  });
}

4. Use proper HTTP methods. GET for reading, POST for creating, PUT/PATCH for updating, DELETE for deleting. Don't make everything POST because it's easier.

Database Queries (Don't Murder Your DB)

This is where scalability really matters. I've seen Next.js apps make 50+ database queries to render a single page. That's not going to scale.

Use Prisma or an ORM. Raw SQL is cool and all, but Prisma gives you type safety, migrations, and a query builder that prevents common mistakes.

Select only what you need:

// Bad - fetches everything
const users = await prisma.user.findMany();

// Good - only fetches what you display
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    email: true,
  }
});

Use includes wisely: Don't fetch related data you don't need.

// Fetch user with their 5 most recent posts
const user = await prisma.user.findUnique({
  where: { id: userId },
  include: {
    posts: {
      take: 5,
      orderBy: { createdAt: 'desc' },
      select: { id: true, title: true, createdAt: true }
    }
  }
});

Implement caching: For data that doesn't change often, cache it. Redis is great, but even in-memory caching helps.

import { unstable_cache } from 'next/cache';

const getSettings = unstable_cache(
  async () => prisma.settings.findFirst(),
  ['app-settings'],
  { revalidate: 3600 } // Cache for 1 hour
);

Image Optimization (Low-Hanging Fruit)

Next.js Image component is magical. Use it. Always.

// Bad
Hero

// Good
Hero

This automatically optimizes images, lazy loads them, and serves modern formats like WebP. I've seen this alone improve page load times by 40%.

Monitoring and Performance

You can't optimize what you don't measure. Add monitoring from day one.

Vercel Analytics: If you're deploying on Vercel, enable analytics. It's literally one click and shows you real user performance metrics.

Custom logging: Log slow API routes, failed requests, and errors to a service like Sentry or LogRocket.

// Log slow API routes
export default async function handler(req, res) {
  const start = Date.now();
  
  try {
    // Your logic here
  } finally {
    const duration = Date.now() - start;
    if (duration > 1000) {
      console.warn(`Slow API route: ${req.url} took ${duration}ms`);
    }
  }
}

Environment Variables and Secrets

This is basic but I've seen it screwed up so many times. Never commit API keys. Use environment variables properly.

// .env.local (gitignored)
DATABASE_URL=postgresql://...
API_KEY=secret123

// Access in API routes
const apiKey = process.env.API_KEY;

// For client-side (must be prefixed with NEXT_PUBLIC_)
NEXT_PUBLIC_APP_URL=https://myapp.com

Have different env files for development, staging, and production. Don't mix them up or you'll accidentally nuke production data (I've seen this happen, it's not pretty).

Testing Strategy

I used to skip tests for "move fast" reasons. Then I spent 2 days debugging a regression that would've been caught by a 5-minute test. Tests are worth it.

Unit tests: For utility functions, hooks, and components with complex logic. Use Jest + React Testing Library.

Integration tests: For critical user flows. Playwright or Cypress. Test the happy path and common error cases.

E2E tests: For absolutely critical flows (signup, checkout, etc). These are slow but catch issues that unit tests miss.

Start small. Test the most important stuff first. You don't need 100% coverage.

Deployment and CI/CD

Automate everything. When you push to main, it should automatically: run tests, check linting, build the app, and deploy if everything passes.

GitHub Actions example:

name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: npm ci
      - run: npm run lint
      - run: npm run test
      - run: npm run build
      - uses: vercel/action@v1
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}

This gives you confidence that broken code never reaches production.

Real Talk: What Actually Matters

You can optimize forever. There's always another thing to improve. But here's what I've found actually moves the needle:

  • Code splitting - Biggest performance win for the effort
  • Image optimization - Use Next.js Image component everywhere
  • Efficient database queries - One slow query can kill your whole app
  • Proper caching - Don't recalculate things you've already calculated
  • Good project structure - Makes everything else easier

Everything else is optimization theater unless you're actually having scaling issues.

Final Thoughts

Building scalable Next.js apps isn't about knowing every advanced feature. It's about making good architectural decisions early and not creating a mess you'll regret 6 months later.

Start with good structure, split your code thoughtfully, manage state appropriately, and monitor your performance. Do those things and you'll be fine as you scale from 100 users to 100,000.

And remember: premature optimization is still the root of all evil. Build features first, optimize when you have actual metrics showing you need to. Don't spend a week optimizing a page that loads in 800ms when your actual bottleneck is a 5-second API call.

Now go build something awesome. And for the love of all that's holy, use the Image component.