Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Provide noscript fallback content

A noscript tag provides fallback content for users with JavaScript disabled.

Utilities
Quick take
Typical fix time 10 min
  • 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
Why it matters: Users with JavaScript disabled, blocked by corporate firewalls, or with failed script loads see blank pages without proper noscript fallbacks.

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>
}
<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

  1. Provide Meaningful Fallbacks: Don't just show "JavaScript is required"
  2. Maintain Functionality: Core features should work without JavaScript
  3. Progressive Enhancement: Start with working HTML, enhance with JS
  4. Clear Communication: Explain what features require JavaScript
  5. Test Without JavaScript: Regularly test your site with JS disabled
  6. Use Semantic HTML: Rely on native HTML functionality as foundation
  7. Server-Side Processing: Ensure forms and critical features work server-side
  8. Graceful Degradation: Enhanced features should fail gracefully
  9. Use feature detection: Branch on supported APIs, not user-agent strings
  10. 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.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Nu Html Checker
validator.w3.orgTool

Rules that often go hand-in-hand with this one.

Make notifications accessible

Toast notifications and alerts are announced to screen readers using ARIA live regions and appropriate roles.

Accessibility
Create accessible tooltips

Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling.

Accessibility
Make custom elements and Web Components accessible

Custom elements must implement ARIA reflection via ElementInternals, keyboard interaction, and form association so that screen readers and assistive technologies can interpret them correctly.

HTML
Make carousels accessible

Carousels and sliders are accessible with pause controls, keyboard navigation, and proper ARIA attributes.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385