Skip to main content

Next.js Custom Server

Introduction

By default, Next.js provides a built-in server that handles routing, rendering, and other core functionalities. However, there are scenarios where you might need more control over the server behavior. This is where a Custom Server comes into play.

A Custom Server allows you to:

  • Start your Next.js app programmatically
  • Use custom server-side routing logic
  • Connect to server-side databases directly
  • Integrate with existing Node.js applications or Express.js servers
  • Implement custom middleware for authentication, logging, etc.

However, it's important to note that using a custom server comes with some trade-offs. You'll lose key Next.js features like:

  • Automatic static optimization
  • Faster builds
  • Smaller server bundles
  • Serverless deployment options

Let's dive into how to set up a custom server and explore different use cases!

Setting Up a Basic Custom Server

To create a custom server, you need to create a new file (commonly named server.js) at the root of your project:

js
// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
}).listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
});

After creating this file, you'll need to modify your package.json to use this custom server instead of the default Next.js server:

json
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}

Now when you run npm run dev or yarn dev, your Next.js application will use your custom server implementation instead of the default one.

Custom Routing with a Custom Server

One of the main benefits of using a custom server is implementing custom routing logic. Here's an example that handles a custom route pattern:

js
// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
const { pathname, query } = parsedUrl;

// Custom routing rules
if (pathname === '/products') {
app.render(req, res, '/product-list', query);
} else if (pathname.startsWith('/p/')) {
// Extract product ID from URL
const id = pathname.split('/')[2];
app.render(req, res, '/product', { id, ...query });
} else {
handle(req, res, parsedUrl);
}
}).listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
});

In this example:

  • When users visit /products, they'll see the content from /product-list page
  • When users visit /p/123, they'll see the content from /product page with the ID parameter set to "123"
  • All other routes are handled by Next.js's default routing system

Using Express.js with Next.js

Express is a popular Node.js web framework that you can integrate with Next.js for more complex server-side logic:

js
// server.js
const express = require('express');
const next = require('next');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
const server = express();

// Middleware example
server.use((req, res, next) => {
console.log(`Request to: ${req.url}`);
next();
});

// Custom API endpoint
server.get('/api/hello', (req, res) => {
res.json({ message: 'Hello from custom server!' });
});

// Custom page routing
server.get('/products/:id', (req, res) => {
return app.render(req, res, '/product', { id: req.params.id });
});

// Let Next.js handle all other routes
server.all('*', (req, res) => {
return handle(req, res);
});

server.listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
});

With this setup, you can:

  • Use Express middleware
  • Create custom API endpoints directly in your server
  • Use Express's routing system alongside Next.js

Real-World Example: Authentication Middleware

Here's a more practical example demonstrating how to implement basic authentication middleware with a custom server:

js
// server.js
const express = require('express');
const next = require('next');
const session = require('express-session');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

// Mock authentication data
const users = {
'user1': { password: 'password1', name: 'User One' },
'user2': { password: 'password2', name: 'User Two' }
};

app.prepare().then(() => {
const server = express();

// Set up session middleware
server.use(
session({
secret: 'my-secret-key',
resave: false,
saveUninitialized: false,
})
);

// Parse JSON request bodies
server.use(express.json());

// Login endpoint
server.post('/api/login', (req, res) => {
const { username, password } = req.body;

if (users[username] && users[username].password === password) {
req.session.user = {
username,
name: users[username].name,
};
res.status(200).json({ success: true });
} else {
res.status(401).json({ success: false, message: 'Invalid credentials' });
}
});

// Authentication middleware for protected routes
const requireAuth = (req, res, next) => {
if (req.session.user) {
next();
} else {
res.redirect('/login');
}
};

// Protected route example
server.get('/dashboard', requireAuth, (req, res) => {
return app.render(req, res, '/dashboard', { user: req.session.user });
});

// Handle all other routes
server.all('*', (req, res) => {
return handle(req, res);
});

server.listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
});

In this example, we've implemented:

  1. Session handling with express-session
  2. A login API endpoint that authenticates users
  3. A middleware function that protects certain routes
  4. A protected dashboard route that requires authentication

Custom Server with WebSockets

Another powerful use case for a custom server is implementing real-time functionality with WebSockets:

js
// server.js
const express = require('express');
const http = require('http');
const next = require('next');
const WebSocket = require('ws');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
const server = express();

// Regular HTTP routes
server.all('*', (req, res) => {
return handle(req, res);
});

// Create HTTP server
const httpServer = http.createServer(server);

// Set up WebSocket server
const wss = new WebSocket.Server({ server: httpServer });

// WebSocket connection handling
wss.on('connection', (ws) => {
console.log('Client connected');

// Send welcome message
ws.send(JSON.stringify({
type: 'connection',
message: 'Connected to WebSocket server'
}));

// Handle messages from client
ws.on('message', (message) => {
console.log('Received:', message);

// Echo the message back
ws.send(JSON.stringify({
type: 'echo',
message: `Echo: ${message}`
}));
});

ws.on('close', () => {
console.log('Client disconnected');
});
});

// Start server
httpServer.listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
});

On the client side, you can connect to this WebSocket server like this:

jsx
// pages/index.js
import { useState, useEffect } from 'react';

export default function Home() {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [socket, setSocket] = useState(null);

useEffect(() => {
// Create WebSocket connection
const ws = new WebSocket(`ws://${window.location.host}`);

ws.onopen = () => {
console.log('Connected to WebSocket');
setSocket(ws);
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setMessages((prev) => [...prev, data]);
};

ws.onclose = () => {
console.log('Disconnected from WebSocket');
};

// Clean up on unmount
return () => {
ws.close();
};
}, []);

const sendMessage = (e) => {
e.preventDefault();
if (socket && inputValue) {
socket.send(inputValue);
setInputValue('');
}
};

return (
<div>
<h1>WebSocket Demo</h1>

<div style={{ marginBottom: 20 }}>
<form onSubmit={sendMessage}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Type a message"
/>
<button type="submit">Send</button>
</form>
</div>

<div style={{ border: '1px solid #ccc', padding: 10, height: 300, overflowY: 'auto' }}>
{messages.map((msg, index) => (
<div key={index} style={{ marginBottom: 10 }}>
<strong>{msg.type}:</strong> {msg.message}
</div>
))}
</div>
</div>
);
}

Best Practices and Considerations

When implementing a custom server for your Next.js application, keep these best practices in mind:

  1. Performance Impact: Custom servers disable some Next.js optimizations. Only use them when necessary.

  2. Code Organization: Keep your server code modular and well-organized.

  3. Environment Variables: Use environment variables for configuration rather than hardcoding values.

  4. Error Handling: Implement proper error handling to prevent server crashes.

  5. Deployment Considerations: A custom server requires a Node.js environment for deployment and can't use serverless deployments.

  6. Security: Be careful when implementing authentication, rate limiting, and other security-related features.

Example of better error handling:

js
// server.js with improved error handling
const express = require('express');
const next = require('next');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
const server = express();

// Global error handler middleware
server.use((err, req, res, next) => {
console.error('Server error:', err);
res.status(500).send('Something broke on our end! Please try again later.');
});

// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
// Consider implementing graceful shutdown here
});

process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

// Route handling
server.all('*', (req, res) => {
return handle(req, res).catch(err => {
console.error('Error handling request:', err);
res.status(500).send('Server error');
});
});

// Use environment variable for port
const PORT = process.env.PORT || 3000;
server.listen(PORT, (err) => {
if (err) throw err;
console.log(`> Ready on http://localhost:${PORT}`);
});
});

Summary

Next.js custom servers provide powerful flexibility for advanced use cases where the default server behavior isn't sufficient. We've learned:

  • How to set up a basic custom server
  • Implementing custom routing logic
  • Integrating with Express.js for middleware and advanced routing
  • Creating real-world applications with authentication
  • Adding WebSocket support for real-time functionality
  • Best practices for deploying and maintaining custom servers

While custom servers are powerful, remember that they come with trade-offs. The Next.js team recommends using the built-in functionality whenever possible, and only reaching for custom servers when you need the extra flexibility they provide.

Additional Resources

Exercises

  1. Build a custom Next.js server with Express that includes rate limiting middleware for API routes.

  2. Create a simple chat application using Next.js with a WebSocket server.

  3. Implement a custom server that connects to a database (like MongoDB) and adds server-side caching.

  4. Build a custom authentication system with session management using a custom server.

  5. Create a proxy server using a Next.js custom server that forwards requests to different backend services based on the route.

Happy coding!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)