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:
// 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:
{
"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:
// 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:
// 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:
// 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:
- Session handling with
express-session
- A login API endpoint that authenticates users
- A middleware function that protects certain routes
- 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:
// 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:
// 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:
-
Performance Impact: Custom servers disable some Next.js optimizations. Only use them when necessary.
-
Code Organization: Keep your server code modular and well-organized.
-
Environment Variables: Use environment variables for configuration rather than hardcoding values.
-
Error Handling: Implement proper error handling to prevent server crashes.
-
Deployment Considerations: A custom server requires a Node.js environment for deployment and can't use serverless deployments.
-
Security: Be careful when implementing authentication, rate limiting, and other security-related features.
Example of better error handling:
// 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
-
Build a custom Next.js server with Express that includes rate limiting middleware for API routes.
-
Create a simple chat application using Next.js with a WebSocket server.
-
Implement a custom server that connects to a database (like MongoDB) and adds server-side caching.
-
Build a custom authentication system with session management using a custom server.
-
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! :)