Provide noscript fallback content
A noscript tag provides fallback content for users with JavaScript disabled.
- Add <noscript> with helpful message for JS-dependent features
- Provide alternative content, not just 'Enable JavaScript'
- Use for critical features: analytics fallback, lazy-loaded images
- Consider progressive enhancement: content works without JS
- Important content should be present in the initial HTML, not fetched only after hydration
Rule Details
The <noscript> element provides fallback content for users who have JavaScript disabled or unavailable, ensuring accessibility and progressive enhancement.
Code Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Progressive Enhancement Example</title>
<!-- Critical CSS loaded normally -->
<link rel="stylesheet" href="/css/critical.css">
<!-- Non-critical CSS with noscript fallback -->
<script>
// Load CSS asynchronously
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/enhanced.css';
document.head.appendChild(link);
</script>
<noscript>
<link rel="stylesheet" href="/css/enhanced.css">
</noscript>
</head>
<body>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
<!-- JavaScript-enhanced form with fallback -->
<form action="/search" method="GET">
<input type="search" name="q" placeholder="Search..." required>
<button type="submit">Search</button>
<noscript>
<p><strong>Note:</strong> JavaScript is disabled.
Search results will be displayed on a new page.</p>
</noscript>
</form>
<!-- Enhanced content with fallback -->
<div id="dynamic-content">
<noscript>
<p>This content requires JavaScript to display properly.
Please enable JavaScript or visit our
<a href="/sitemap">sitemap</a> for all content.</p>
</noscript>
</div>
</body>
</html>Why It Matters
Users with JavaScript disabled, blocked by corporate firewalls, or with failed script loads see blank pages without proper noscript fallbacks.
Progressive enhancement starts with working HTML, not with client JavaScript. The initial document should already contain the important copy, links, and form actions. JavaScript should enhance search suggestions, filters, infinite scroll, and validation rather than being the only path to content.
Common Use Cases
Prefer Feature Detection Over UA Sniffing
Browser or device sniffing is fragile and regularly misclassifies embedded browsers, compatibility modes, and future versions. Detect the capability you need instead:
// ❌ Bad: user-agent sniffing
if (/iPhone|Android/.test(navigator.userAgent)) {
enableTouchMenu()
}
// ✅ Good: feature detection
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
enableTouchMenu()
}
if ('IntersectionObserver' in window) {
enableInfiniteScroll()
} else {
showPaginationLinks()
}Avoid Hydration Mismatches
If the server renders one value and the client immediately replaces it with another, users can lose context and assistive technology can announce stale content. Keep server and client output aligned for critical content.
// ❌ Bad: server and client render different primary content
function Greeting() {
return <h1>{typeof window === 'undefined' ? 'Welcome' : window.location.pathname}</h1>
}
// ✅ Good: server renders stable content, JS enhances after mount
function Greeting({ path }: { path: string }) {
return <h1>Viewing {path}</h1>
}Navigation Fallbacks
<nav>
<!-- Mobile menu button (JavaScript enhanced) -->
<button id="mobile-menu-btn" class="mobile-menu-toggle" aria-expanded="false">
Menu
</button>
<!-- Navigation menu -->
<ul id="main-nav" class="nav-menu">
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<noscript>
<style>
.mobile-menu-toggle { display: none !important; }
.nav-menu { display: block !important; }
</style>
<p><em>All navigation links are available above.</em></p>
</noscript>
</nav>
<script>
// Enhanced mobile menu functionality
document.getElementById('mobile-menu-btn').addEventListener('click', function() {
const nav = document.getElementById('main-nav');
const expanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', !expanded);
nav.classList.toggle('open');
});
</script>Form Enhancement with Fallbacks
<form action="/contact" method="POST" id="contact-form">
<div class="form-group">
<label for="name">Name *</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required>
<div id="email-error" class="error-message" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" required></textarea>
<div id="char-count" class="char-counter">0/500 characters</div>
</div>
<button type="submit" id="submit-btn">Send Message</button>
<div id="form-status" aria-live="polite"></div>
<noscript>
<div class="noscript-notice">
<p><strong>JavaScript Disabled:</strong></p>
<ul>
<li>Form will submit normally but without real-time validation</li>
<li>Character counting is not available</li>
<li>You'll be redirected to a confirmation page after submission</li>
</ul>
</div>
</noscript>
</form>
<script>
// Enhanced form functionality
const form = document.getElementById('contact-form');
const messageField = document.getElementById('message');
const charCount = document.getElementById('char-count');
// Character counter
messageField.addEventListener('input', function() {
const count = this.value.length;
charCount.textContent = `${count}/500 characters`;
charCount.className = count > 500 ? 'char-counter over-limit' : 'char-counter';
});
// AJAX form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Enhanced submission logic here
});
</script>Content Loading with Fallbacks
<section id="product-gallery">
<h2>Product Gallery</h2>
<!-- Placeholder content for no-JS users -->
<div class="static-gallery">
<div class="product-grid">
<div class="product-item">
<img src="/images/product-1.jpg" alt="Product 1">
<h3>Product 1</h3>
<p>$29.99</p>
<a href="/products/1" class="btn">View Details</a>
</div>
<div class="product-item">
<img src="/images/product-2.jpg" alt="Product 2">
<h3>Product 2</h3>
<p>$39.99</p>
<a href="/products/2" class="btn">View Details</a>
</div>
</div>
<div class="pagination">
<a href="/products?page=1">1</a>
<a href="/products?page=2">2</a>
<a href="/products?page=3">3</a>
</div>
</div>
<noscript>
<p>Showing all products. Enhanced filtering and
infinite scroll require JavaScript.</p>
</noscript>
</section>
<script>
// Replace static gallery with enhanced version
document.addEventListener('DOMContentLoaded', function() {
const gallery = document.getElementById('product-gallery');
// Initialize enhanced gallery with filtering, search, infinite scroll
initEnhancedGallery(gallery);
});
</script>Framework Examples
Next.js with NoScript Support
// components/EnhancedSearch.js
import { useState, useEffect } from 'react'
export default function EnhancedSearch({ fallbackAction = "/search" }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
const handleSearch = async (e) => {
if (!isClient) return // Let form submit normally
e.preventDefault()
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
const data = await response.json()
setResults(data.results)
} catch (error) {
console.error('Search failed:', error)
// Fallback to regular form submission
window.location.href = `${fallbackAction}?q=${encodeURIComponent(query)}`
}
}
return (
<div className="search-container">
<form action={fallbackAction} method="GET" onSubmit={handleSearch}>
<input
type="search"
name="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
required
/>
<button type="submit">Search</button>
</form>
{!isClient && (
<noscript>
<div className="noscript-notice">
<p>JavaScript is disabled. Search results will open in a new page.</p>
</div>
</noscript>
)}
{isClient && results.length > 0 && (
<div className="search-results">
{results.map(result => (
<div key={result.id} className="result-item">
<h3>{result.title}</h3>
<p>{result.description}</p>
</div>
))}
</div>
)}
</div>
)
}
// pages/search.js (fallback page)
export default function SearchResults({ query, results }) {
return (
<div>
<h1>Search Results for "{query}"</h1>
{results.map(result => (
<div key={result.id} className="result-item">
<h3>{result.title}</h3>
<p>{result.description}</p>
<a href={result.url}>View Details</a>
</div>
))}
</div>
)
}
export async function getServerSideProps({ query }) {
const searchQuery = query.q || ''
const results = await searchProducts(searchQuery)
return {
props: {
query: searchQuery,
results
}
}
}React with Progressive Enhancement
import React, { useState, useEffect, useRef } from 'react'
function ProgressiveContactForm() {
const [isEnhanced, setIsEnhanced] = useState(false)
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
})
const [errors, setErrors] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
const formRef = useRef(null)
useEffect(() => {
// Enable enhanced features after component mounts
setIsEnhanced(true)
// Hide noscript content when JS is available
const noscriptElements = document.querySelectorAll('noscript')
noscriptElements.forEach(el => {
el.style.display = 'none'
})
}, [])
const validateField = (name, value) => {
switch (name) {
case 'email':
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? '' : 'Please enter a valid email'
case 'name':
return value.length >= 2
? '' : 'Name must be at least 2 characters'
case 'message':
return value.length >= 10
? '' : 'Message must be at least 10 characters'
default:
return ''
}
}
const handleChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
if (isEnhanced) {
const error = validateField(name, value)
setErrors(prev => ({ ...prev, [name]: error }))
}
}
const handleSubmit = async (e) => {
if (!isEnhanced) {
// Let the form submit normally
return
}
e.preventDefault()
setIsSubmitting(true)
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (response.ok) {
alert('Message sent successfully!')
setFormData({ name: '', email: '', message: '' })
} else {
throw new Error('Submission failed')
}
} catch (error) {
alert('Failed to send message. Please try again.')
} finally {
setIsSubmitting(false)
}
}
return (
<form
ref={formRef}
action="/contact"
method="POST"
onSubmit={handleSubmit}
>
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
{isEnhanced && errors.name && (
<span className="error">{errors.name}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
{isEnhanced && errors.email && (
<span className="error">{errors.email}</span>
)}
</div>
<div className="form-group">
<label htmlFor="message">Message *</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
/>
{isEnhanced && errors.message && (
<span className="error">{errors.message}</span>
)}
{isEnhanced && (
<small>{formData.message.length}/500 characters</small>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
<noscript>
<div className="noscript-notice">
<p><strong>JavaScript is disabled:</strong></p>
<ul>
<li>Form will submit to /contact endpoint</li>
<li>Real-time validation unavailable</li>
<li>You'll see a confirmation page after submission</li>
</ul>
</div>
</noscript>
</form>
)
}Vue.js with NoScript Handling
<template>
<div class="enhanced-component">
<!-- Enhanced content shown when JS is available -->
<div v-if="isClientSide" class="js-enhanced">
<h2>Enhanced Gallery</h2>
<div class="filters">
<button
v-for="category in categories"
:key="category"
@click="filterByCategory(category)"
:class="{ active: selectedCategory === category }"
>
{{ category }}
</button>
</div>
<div class="image-grid">
<div
v-for="image in filteredImages"
:key="image.id"
class="image-item"
@click="openLightbox(image)"
>
<img :src="image.thumbnail" :alt="image.alt">
<p>{{ image.title }}</p>
</div>
</div>
<div v-if="showLightbox" class="lightbox" @click="closeLightbox">
<img :src="selectedImage.full" :alt="selectedImage.alt">
</div>
</div>
<!-- Fallback content structure -->
<div v-else class="no-js-fallback">
<h2>Image Gallery</h2>
<div class="static-grid">
<div v-for="image in images" :key="image.id" class="static-item">
<a :href="image.full" target="_blank">
<img :src="image.thumbnail" :alt="image.alt">
<p>{{ image.title }} ({{ image.category }})</p>
</a>
</div>
</div>
</div>
<!-- NoScript element for pure HTML version -->
<noscript>
<div class="noscript-gallery">
<h2>Image Gallery (JavaScript Disabled)</h2>
<p>Click images to view full size in new window.</p>
<div class="noscript-grid">
<!-- Server-rendered static content would go here -->
</div>
</div>
</noscript>
</div>
</template>
<script>
export default {
data() {
return {
isClientSide: false,
images: [
{ id: 1, title: 'Sunset', category: 'Nature', thumbnail: '/thumb1.jpg', full: '/full1.jpg', alt: 'Beautiful sunset' },
{ id: 2, title: 'City', category: 'Urban', thumbnail: '/thumb2.jpg', full: '/full2.jpg', alt: 'City skyline' }
],
categories: ['All', 'Nature', 'Urban', 'People'],
selectedCategory: 'All',
showLightbox: false,
selectedImage: null
}
},
computed: {
filteredImages() {
if (this.selectedCategory === 'All') {
return this.images
}
return this.images.filter(img => img.category === this.selectedCategory)
}
},
mounted() {
// Enable client-side features
this.isClientSide = true
// Handle keyboard navigation
document.addEventListener('keydown', this.handleKeydown)
},
beforeUnmount() {
document.removeEventListener('keydown', this.handleKeydown)
},
methods: {
filterByCategory(category) {
this.selectedCategory = category
},
openLightbox(image) {
this.selectedImage = image
this.showLightbox = true
document.body.style.overflow = 'hidden'
},
closeLightbox() {
this.showLightbox = false
this.selectedImage = null
document.body.style.overflow = 'auto'
},
handleKeydown(e) {
if (e.key === 'Escape' && this.showLightbox) {
this.closeLightbox()
}
}
}
}
</script>CSS for NoScript Styling
/* Hide enhanced elements when JS is disabled */
.js-only {
display: none;
}
/* Show enhanced elements when JS is available */
.js-enabled .js-only {
display: block;
}
/* Style noscript notices */
noscript {
display: block;
background: #f0f0f0;
border: 1px solid #ccc;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
}
.noscript-notice {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
/* Progressive enhancement styles */
.enhanced-form {
position: relative;
}
.enhanced-form .loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
display: none;
}
.enhanced-form.submitting .loading-overlay {
display: flex;
}
/* Hide JS-dependent elements initially */
.char-counter,
.real-time-validation,
.auto-save-indicator {
opacity: 0;
transition: opacity 0.3s;
}
/* Show them when JS loads */
.js-loaded .char-counter,
.js-loaded .real-time-validation,
.js-loaded .auto-save-indicator {
opacity: 1;
}Best Practices
- Provide Meaningful Fallbacks: Don't just show "JavaScript is required"
- Maintain Functionality: Core features should work without JavaScript
- Progressive Enhancement: Start with working HTML, enhance with JS
- Clear Communication: Explain what features require JavaScript
- Test Without JavaScript: Regularly test your site with JS disabled
- Use Semantic HTML: Rely on native HTML functionality as foundation
- Server-Side Processing: Ensure forms and critical features work server-side
- Graceful Degradation: Enhanced features should fail gracefully
- Use feature detection: Branch on supported APIs, not user-agent strings
- Keep HTML authoritative: Important content, links, and pagination must exist before hydration
Testing NoScript Implementation
Browser Testing
// Programmatically disable JavaScript for testing
Object.defineProperty(window, 'navigator', {
value: {
...window.navigator,
javaEnabled: () => false
}
});
// Simulate noscript environment
document.querySelectorAll('script').forEach(script => {
script.remove();
});Automated Testing
// Puppeteer test with JavaScript disabled
const puppeteer = require('puppeteer');
async function testNoScript() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Disable JavaScript
await page.setJavaScriptEnabled(false);
await page.goto('http://localhost:3000');
// Test form submission
await page.type('input[name="email"]', 'test@example.com');
await page.click('button[type="submit"]');
// Verify redirect to success page
await page.waitForNavigation();
const url = page.url();
console.log('Redirected to:', url);
await browser.close();
}The <noscript> element is essential for creating truly accessible and robust web applications that work for all users, regardless of their JavaScript capabilities.
Verification
Automated Checks
- Test one primary path and one edge case affected by the change.
- Use browser or CI tooling where applicable to verify the fix.
- Re-check shared abstractions so the fix is applied consistently.
Manual Checks
- Confirm the rule in the final rendered output or runtime behavior.
- Disable JavaScript and confirm the primary content, navigation, and pagination are still reachable from HTML alone.
- Compare server-rendered HTML with hydrated UI and confirm no critical headings, counts, or body copy change unexpectedly on mount.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Verify that this page provides appropriate noscript fallbacks for users with JavaScript disabled or unavailable, ensuring core functionality remains accessible. Confirm important content exists in the initial HTML and is not blocked on hydration.
Fix
Auto-fix issues
Add noscript elements with alternative content or functionality for critical features that depend on JavaScript. Replace UA-sniffed behavior with feature detection and make sure the server-rendered HTML already contains the important content and pagination paths.
Explain
Learn more
Explain why noscript fallbacks are important for accessibility, progressive enhancement, and ensuring your site works for all users regardless of their JavaScript support. Explain why feature detection is safer than UA sniffing and why hydration mismatches can break progressive enhancement.
Review
Code review
Review templates, server-rendered HTML, and shared components that output markup related to Provide noscript fallback content. Flag exact elements, attributes, and routes where the rendered HTML violates the rule.