Next.js Security Vulnerabilities and Attack Vectors 2025
Next.js has revolutionized React development with its powerful features like Server-Side Rendering (SSR), Static Site Generation (SSG), API routes, and middleware. However, these powerful features also introduce unique security challenges. In this comprehensive 2025 guide, we'll explore Next.js-specific vulnerabilities, common attack vectors, and advanced protection strategies that go beyond general web security practices.
Next.js Security Landscape 2025
Next.js applications are particularly attractive to attackers because they often handle:
- Server-side rendering with sensitive data
- API routes processing user inputs
- Authentication and authorization logic
- Static generation with dynamic content
- Middleware intercepting all requests
Critical Next.js Vulnerabilities (2024-2025)
1. Next.js 14.0.0 - Server-Side Request Forgery (SSRF) in Image Optimization
A critical SSRF vulnerability allowed attackers to make internal network requests through Next.js's image optimization API.
Vulnerable Configuration:
// next.config.js - VULNERABLE
module.exports = {
images: {
domains: ['*'], // TOO PERMISSIVE
remotePatterns: [
{
protocol: 'https',
hostname: '**', // ALLOWS ANY DOMAIN
}
]
}
}Secure Configuration:
// next.config.js - SECURE
module.exports = {
images: {
domains: ['trusted-cdn.com', 'my-domain.com'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.myapp.com',
port: '',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
port: '',
pathname: '/u/**',
}
],
dangerouslyAllowSVG: false,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}
}2. API Route Injection Vulnerability (2024)
Attackers could inject malicious code through API route parameters, leading to remote code execution.
Vulnerable API Route:
// pages/api/user/[id].js or app/api/user/[id]/route.js - VULNERABLE
export default async function handler(req, res) {
const { id } = req.query;
// DANGEROUS: Direct string interpolation
const query = `SELECT * FROM users WHERE id = '${id}'`;
const result = await db.query(query);
res.status(200).json(result);
}Secure API Route:
// pages/api/user/[id].js or app/api/user/[id]/route.js - SECURE
import sql from '@/lib/db';
export default async function handler(req, res) {
const { id } = req.query;
// Validate input
if (!id || typeof id !== 'string' || id.length > 50) {
return res.status(400).json({ error: 'Invalid user ID' });
}
// Use parameterized queries
const result = await sql`
SELECT id, name, email FROM users
WHERE id = ${id}
AND deleted_at IS NULL
`;
if (result.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(result[0]);
}3. Middleware Authentication Bypass (2024)
Next.js middleware could be bypassed using specific request patterns, allowing unauthorized access to protected routes.
Vulnerable Middleware:
// middleware.js - VULNERABLE
import { NextResponse } from 'next/server';
export function middleware(request) {
const token = request.cookies.get('auth-token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// MISSING: Token validation
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/api/private/:path*'],
};Secure Middleware:
// middleware.js - SECURE
import { NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
export async function middleware(request) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
// Validate token
const payload = await verifyToken(token);
// Check token expiration
if (payload.exp < Date.now() / 1000) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Add user info to headers for API routes
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId);
requestHeaders.set('x-user-role', payload.role);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
} catch (error) {
console.error('Token verification failed:', error);
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: [
'/dashboard/:path*',
'/api/private/:path*',
'/((?!login|register|_next/static|_next/image|favicon.ico).*)',
],
};Next.js-Specific Attack Vectors
1. Server-Side Rendering (SSR) Injection Attacks
Dynamic Import Injection:
// VULNERABLE - User-controlled dynamic imports
const ComponentPage = ({ componentName }) => {
const Component = dynamic(() => import(`../components/${componentName}`));
return <Component />;
};Secure Alternative:
// SECURE - Whitelist allowed components
const ALLOWED_COMPONENTS = {
'Hero': () => import('../components/Hero'),
'About': () => import('../components/About'),
'Contact': () => import('../components/Contact'),
};
const ComponentPage = ({ componentName }) => {
const ComponentLoader = ALLOWED_COMPONENTS[componentName];
if (!ComponentLoader) {
return <div>Component not found</div>;
}
const Component = dynamic(ComponentLoader);
return <Component />;
};2. Static Site Generation (SSG) Data Poisoning
Attackers can poison statically generated pages through malicious data sources.
Vulnerable getStaticProps:
// pages/blog/[slug].js - VULNERABLE
export async function getStaticProps({ params }) {
const { slug } = params;
// DANGEROUS: No input validation
const post = await fetch(`https://api.external-blog.com/posts/${slug}`)
.then(res => res.json());
return {
props: {
post,
},
revalidate: 3600,
};
}Secure getStaticProps:
// pages/blog/[slug].js - SECURE
export async function getStaticProps({ params }) {
const { slug } = params;
// Validate slug format
if (!slug || typeof slug !== 'string' || !/^[a-zA-Z0-9-]+$/.test(slug)) {
return {
notFound: true,
};
}
try {
const response = await fetch(`https://api.internal-blog.com/posts/${slug}`, {
headers: {
'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
'User-Agent': 'Next.js-SSG/1.0',
},
// Set timeout
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const post = await response.json();
// Validate post structure
if (!post.title || !post.content || !Array.isArray(post.tags)) {
throw new Error('Invalid post structure');
}
// Sanitize content
const sanitizedPost = {
...post,
title: sanitizeHtml(post.title, { allowedTags: [] }),
content: sanitizeHtml(post.content, {
allowedTags: ['p', 'br', 'strong', 'em', 'h1', 'h2', 'h3'],
}),
tags: post.tags.filter(tag => typeof tag === 'string' && tag.length < 50),
};
return {
props: {
post: sanitizedPost,
},
revalidate: 3600,
};
} catch (error) {
console.error('Error fetching post:', error);
return {
notFound: true,
};
}
}
export async function getStaticPaths() {
// Generate paths for known posts only
const posts = await fetch('https://api.internal-blog.com/posts?limit=100')
.then(res => res.json());
return {
paths: posts.map(post => ({
params: { slug: post.slug },
})),
fallback: 'blocking', // More secure than 'true'
};
}3. API Route Path Traversal
File system access through API route parameters.
Vulnerable API Route:
// pages/api/files/[...path].js - VULNERABLE
import fs from 'fs';
import path from 'path';
export default function handler(req, res) {
const filePath = path.join(process.cwd(), 'uploads', ...req.query.path);
// DANGEROUS: No path validation
const content = fs.readFileSync(filePath);
res.status(200).send(content);
}Secure API Route:
// pages/api/files/[...path].js - SECURE
import fs from 'fs';
import path from 'path';
export default function handler(req, res) {
const { path: pathSegments } = req.query;
// Validate path segments
if (!Array.isArray(pathSegments) || pathSegments.length === 0) {
return res.status(400).json({ error: 'Invalid path' });
}
// Validate each segment
for (const segment of pathSegments) {
if (typeof segment !== 'string' ||
segment.length === 0 ||
segment.length > 100 ||
/[\/:*?"<>|]/.test(segment) ||
segment === '.' ||
segment === '..') {
return res.status(400).json({ error: 'Invalid path segment' });
}
}
const safePath = path.join(process.cwd(), 'uploads', ...pathSegments);
const resolvedPath = path.resolve(safePath);
// Ensure path is within uploads directory
if (!resolvedPath.startsWith(path.resolve(process.cwd(), 'uploads'))) {
return res.status(403).json({ error: 'Access denied' });
}
// Check file extension whitelist
const allowedExtensions = ['.jpg', '.png', '.pdf', '.txt'];
const ext = path.extname(resolvedPath).toLowerCase();
if (!allowedExtensions.includes(ext)) {
return res.status(403).json({ error: 'File type not allowed' });
}
try {
const stat = fs.statSync(resolvedPath);
// Check file size limits
if (stat.size > 10 * 1024 * 1024) { // 10MB limit
return res.status(413).json({ error: 'File too large' });
}
const content = fs.readFileSync(resolvedPath);
// Set appropriate content type
const contentType = ext === '.pdf' ? 'application/pdf' :
ext === '.txt' ? 'text/plain' :
'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', stat.size);
res.status(200).send(content);
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
console.error('File read error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}4. Environment Variable Exposure
Next.js applications can accidentally expose sensitive environment variables.
Vulnerable Client-Side Code:
// pages/settings.js - VULNERABLE
export default function Settings() {
// DANGEROUS: Client-side access to sensitive data
const apiKey = process.env.API_KEY; // This will be undefined/null
const dbPassword = process.env.DATABASE_PASSWORD;
return (
<div>
<p>API Key: {apiKey}</p>
<p>DB Password: {dbPassword}</p>
</div>
);
}Secure Environment Handling:
// .env.local NEXT_PUBLIC_API_URL=https://api.myapp.com DATABASE_URL=postgresql://user:password@localhost:5432/myapp JWT_SECRET=your-super-secure-secret STRIPE_PUBLISHABLE_KEY=pk_live_... STRIPE_SECRET_KEY=sk_live_...
// pages/settings.js - SECURE
import { loadStripe } from '@stripe/stripe-js';
// Client-side: Only public keys
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
export default function Settings() {
return (
<div>
{/* Only expose necessary public information */}
<p>API URL: {process.env.NEXT_PUBLIC_API_URL}</p>
</div>
);
}
// Server-side: Use all environment variables securely
export async function getServerSideProps() {
// This runs on server, environment variables are available
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
try {
const paymentMethods = await stripe.paymentMethods.list({
customer: userId,
type: 'card',
});
return {
props: {
paymentMethods: paymentMethods.data,
},
};
} catch (error) {
return {
props: {
error: 'Failed to load payment methods',
},
};
}
}5. Next.js Image Component Vulnerabilities
The Next.js Image component can be exploited for SSRF attacks.
Vulnerable Image Usage:
// VULNERABLE - User-controlled image URLs
import Image from 'next/image';
export default function UserProfile({ user }) {
return (
<Image
src={user.avatarUrl} // DANGEROUS: User-controlled URL
alt="User avatar"
width={100}
height={100}
/>
);
}Secure Image Handling:
// SECURE - Validate and proxy images
import Image from 'next/image';
export default function UserProfile({ user }) {
// Validate image URL
const isValidImageUrl = (url) => {
try {
const parsedUrl = new URL(url);
return (
(parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:') &&
['avatars.githubusercontent.com', 'cdn.myapp.com'].includes(parsedUrl.hostname)
);
} catch {
return false;
}
};
const imageUrl = isValidImageUrl(user.avatarUrl)
? user.avatarUrl
: '/default-avatar.png';
return (
<Image
src={imageUrl}
alt="User avatar"
width={100}
height={100}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
/>
);
}Advanced Next.js Security Patterns
1. Rate Limiting for API Routes
Protect against brute force and DoS attacks.
API Route with Rate Limiting:
// lib/rate-limit.js
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: {
error: 'Too many requests from this IP, please try again later.',
},
standardHeaders: true,
legacyHeaders: false,
});
// For sensitive operations
const strictLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 requests per minute
message: {
error: 'Too many attempts, please try again in a minute.',
},
});
export { limiter, strictLimiter };// pages/api/auth/login.js
import { strictLimiter } from '@/lib/rate-limit';
export default strictLimiter(async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Authenticate user
try {
const user = await authenticateUser(email, password);
// Generate token
const token = generateToken(user);
res.status(200).json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
// Log failed attempt
await logFailedLogin(email, req.ip);
res.status(401).json({ error: 'Invalid credentials' });
}
});2. Input Sanitization Middleware
Comprehensive input validation for all API routes.
Global Validation Middleware:
// middleware/validation.js
import validator from 'validator';
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('');
const DOMPurifyServer = DOMPurify(window);
export function validateAndSanitizeInput(input, rules) {
const errors = [];
const sanitized = {};
for (const [field, rule] of Object.entries(rules)) {
const value = input[field];
// Required check
if (rule.required && (value === undefined || value === null || value === '')) {
errors.push(`${field} is required`);
continue;
}
if (value === undefined || value === null) {
continue;
}
let sanitizedValue = value;
// Type validation and sanitization
switch (rule.type) {
case 'string':
if (typeof value !== 'string') {
errors.push(`${field} must be a string`);
continue;
}
sanitizedValue = validator.escape(value);
if (rule.maxLength && value.length > rule.maxLength) {
errors.push(`${field} must be less than ${rule.maxLength} characters`);
}
if (rule.minLength && value.length < rule.minLength) {
errors.push(`${field} must be at least ${rule.minLength} characters`);
}
if (rule.pattern && !rule.pattern.test(value)) {
errors.push(`${field} format is invalid`);
}
break;
case 'email':
if (!validator.isEmail(value)) {
errors.push(`${field} must be a valid email address`);
}
sanitizedValue = validator.normalizeEmail(value);
break;
case 'url':
if (!validator.isURL(value, { protocols: ['http', 'https'] })) {
errors.push(`${field} must be a valid URL`);
}
sanitizedValue = validator.escape(value);
break;
case 'html':
sanitizedValue = DOMPurifyServer.sanitize(value, {
ALLOWED_TAGS: rule.allowedTags || [],
ALLOWED_ATTR: rule.allowedAttr || [],
});
break;
case 'number':
const num = Number(value);
if (isNaN(num)) {
errors.push(`${field} must be a number`);
continue;
}
if (rule.min !== undefined && num < rule.min) {
errors.push(`${field} must be at least ${rule.min}`);
}
if (rule.max !== undefined && num > rule.max) {
errors.push(`${field} must be at most ${rule.max}`);
}
sanitizedValue = num;
break;
case 'boolean':
sanitizedValue = Boolean(value);
break;
case 'array':
if (!Array.isArray(value)) {
errors.push(`${field} must be an array`);
continue;
}
if (rule.maxLength && value.length > rule.maxLength) {
errors.push(`${field} must have at most ${rule.maxLength} items`);
}
// Sanitize array items if item rules are provided
if (rule.itemRules) {
sanitizedValue = value.map(item =>
validateAndSanitizeInput({ item }, { item: rule.itemRules }).sanitized.item
).filter(item => item !== undefined);
}
break;
}
sanitized[field] = sanitizedValue;
}
return { sanitized, errors, isValid: errors.length === 0 };
}Usage in API Routes:
// pages/api/contact.js
import { validateAndSanitizeInput } from '@/middleware/validation';
const contactRules = {
name: { type: 'string', required: true, minLength: 2, maxLength: 100 },
email: { type: 'email', required: true },
message: { type: 'string', required: true, minLength: 10, maxLength: 1000 },
website: { type: 'url', required: false },
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { sanitized, errors, isValid } = validateAndSanitizeInput(req.body, contactRules);
if (!isValid) {
return res.status(400).json({ errors });
}
// Process sanitized data
try {
await sendContactEmail(sanitized);
res.status(200).json({ success: true });
} catch (error) {
console.error('Contact form error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}3. Secure Headers Configuration
Comprehensive security headers for Next.js applications.
next.config.js Security Headers:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
// Prevent clickjacking
{
key: 'X-Frame-Options',
value: 'DENY',
},
// Prevent MIME type sniffing
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
// Referrer policy
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
// Prevent XSS
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
// Force HTTPS
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
// Content Security Policy
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"media-src 'self'",
"object-src 'none'",
"frame-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; '),
},
// Permissions policy
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
],
},
// API routes - more restrictive
{
source: '/api/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Content-Security-Policy',
value: "default-src 'none'",
},
],
},
];
},
// Additional security settings
poweredByHeader: false,
compress: true,
reactStrictMode: true,
};4. Database Security Best Practices
Secure database connections and queries in Next.js.
Secure Database Configuration:
// lib/db.js
import { Pool } from 'pg';
import mysql from 'mysql2/promise';
const isProduction = process.env.NODE_ENV === 'production';
// PostgreSQL configuration
const pgPool = isProduction ? new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: true,
},
max: 10, // Maximum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}) : new Pool({
connectionString: process.env.DATABASE_URL,
});
// MySQL configuration
const mysqlPool = isProduction ? mysql.createPool({
uri: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: true,
},
connectionLimit: 10,
acquireTimeout: 60000,
timeout: 60000,
}) : mysql.createPool({
uri: process.env.DATABASE_URL,
});
export { pgPool, mysqlPool };Secure Query Builder:
// lib/secure-query.js
import sql from 'sql-template-tag';
export function buildUserQuery(filters) {
const conditions = [];
const values = [];
if (filters.name) {
conditions.push('name ILIKE $' + (values.length + 1));
values.push(`%${filters.name}%`);
}
if (filters.email) {
conditions.push('email = $' + (values.length + 1));
values.push(filters.email);
}
if (filters.age && typeof filters.age === 'number') {
conditions.push('age >= $' + (values.length + 1));
values.push(filters.age);
}
if (filters.status && ['active', 'inactive'].includes(filters.status)) {
conditions.push('status = $' + (values.length + 1));
values.push(filters.status);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return sql`
SELECT id, name, email, age, status, created_at
FROM users
${whereClause}
ORDER BY created_at DESC
LIMIT 100
`;
}5. Error Handling and Logging
Secure error handling to prevent information leakage.
Global Error Handler:
// pages/_error.js
import Error from 'next/error';
function MyError({ statusCode, hasGetInitialPropsRun, err }) {
// Log error securely
if (err) {
console.error('Application error:', {
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
statusCode,
url: typeof window !== 'undefined' ? window.location.href : undefined,
});
}
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>{statusCode ? `Error ${statusCode}` : 'An error occurred'}</h1>
<p>
{statusCode === 404
? 'The page you are looking for was not found.'
: 'Something went wrong. Please try again later.'}
</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
);
}
MyError.getInitialProps = ({ res, err, asPath }) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
// Log error details server-side
if (err) {
console.error('Server error:', {
message: err.message,
stack: err.stack,
statusCode,
path: asPath,
});
}
return { statusCode };
};
export default MyError;Security Monitoring Setup:
// lib/security-monitor.js
class SecurityMonitor {
static async logSecurityEvent(event, details) {
const logEntry = {
timestamp: new Date().toISOString(),
event,
details,
ip: this.getClientIP(),
userAgent: typeof window !== 'undefined' ? window.navigator.userAgent : 'server',
url: typeof window !== 'undefined' ? window.location.href : 'server',
};
// Store in database or send to monitoring service
try {
await fetch('/api/security/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logEntry),
});
} catch (error) {
console.error('Failed to log security event:', error);
}
// Also log to console in development
if (process.env.NODE_ENV === 'development') {
console.warn('Security Event:', logEntry);
}
}
static getClientIP() {
// Implementation depends on hosting provider
// For Vercel: req.headers['x-forwarded-for']
// For Netlify: req.headers['x-nf-client-connection-ip']
return 'unknown';
}
static logFailedAuth(credentials, ip) {
this.logSecurityEvent('FAILED_AUTH', {
username: credentials.username || credentials.email,
ip,
});
}
static logSuspiciousActivity(activity, details) {
this.logSecurityEvent('SUSPICIOUS_ACTIVITY', {
activity,
details,
});
}
}
export default SecurityMonitor;Next.js Security Checklist 2025
- API Routes: Validate all inputs, use parameterized queries
- Middleware: Implement proper authentication and authorization
- SSR Security: Sanitize getServerSideProps and getStaticProps inputs
- Image Security: Configure domains and validation for Next.js Image
- Environment Variables: Never expose secrets to client-side code
- Rate Limiting: Implement rate limiting on sensitive endpoints
- Headers: Configure comprehensive security headers
- Error Handling: Prevent information leakage in error messages
- Database Security: Use parameterized queries and connection pooling
- Dependencies: Regularly audit and update npm packages
- Logging: Implement security event logging and monitoring
- Input Validation: Validate and sanitize all user inputs
- Authentication: Implement secure session management
- File Uploads: Validate file types, sizes, and contents
- CORS: Configure Cross-Origin Resource Sharing properly
Conclusion
Next.js security requires understanding both general web security principles and Next.js-specific attack vectors. The framework's powerful features like SSR, API routes, and middleware introduce unique security considerations that must be addressed proactively.
Key takeaways for 2025:
- API routes are prime targets for injection attacks
- SSR functions can be exploited through malicious input
- Middleware authentication can be bypassed without proper validation
- Environment variables must never be exposed to client-side code
- Image optimization features can enable SSRF attacks
By implementing comprehensive input validation, secure authentication, proper error handling, and regular security audits, you can build robust, secure Next.js applications that protect both your users and your business from evolving security threats.
Stay vigilant, keep dependencies updated, and implement defense in depth to maintain a strong security posture in your Next.js applications.
Related articles