TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Build a Website Status Checker with a Clean React Interface

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20269 min read6 topics
How to Build a Website Status Checker with a Clean React Interface
Explore the details
#React#Frontend Projects#HTTP Status#Node.js#CORS#SSRF Prevention
October 25, 20269 min read

A website status checker looks simple: enter a URL, send a request, and show a result. The important design decision is where that request runs. A browser cannot reliably read responses from arbitrary websites because of CORS, while a backend that fetches user-supplied URLs can create server-side request forgery (SSRF) risks.

This tutorial builds a React interface that sends a check request to a backend. The example backend is deliberately restricted to an allowlist of approved HTTPS hosts. You’ll see how to handle loading and error states, set a timeout, interpret HTTP status codes, and understand what additional controls are needed before supporting arbitrary targets.

Key takeaways

  • Use React for the form and result display; use a backend to make the HTTP request.
  • CORS can prevent browser JavaScript from reading a cross-origin response. It does not mean the target website is offline.
  • A URL parser or hostname check alone is not an SSRF defense. Restrict destinations and enforce network-level egress controls.
  • A timeout, redirect policy, and clear distinction between HTTP responses and network failures make results more useful.
  • A status code describes one request to one endpoint, not the overall health of an entire website.

Choose a safe architecture

The browser should send a URL to your application’s API, and the API should perform the check:

  1. React validates the input for usability and shows the current state.
  2. The backend validates the request and applies its destination policy.
  3. The backend makes a short, bounded HTTP request and returns a small result.
  4. React displays the status or explains why the check could not be completed.

For a portfolio project, an allowlist of sites you control or have permission to check is a sensible starting point. If you later want users to check arbitrary public URLs, treat that as a security-sensitive feature. The backend must defend against requests to loopback, private, link-local, and otherwise restricted addresses; DNS changes and redirects must also be considered. Use network egress restrictions as a backstop. A parsed URL or hostname allowlist by itself does not provide complete SSRF protection.

If you only need to inspect a URL’s parts rather than make a network request, a URL parser can help with that separate task. Parsing a URL does not check whether its destination is reachable.

Build the React interface

The component below posts to a same-origin API endpoint. It includes input validation for a better user experience, but the backend must repeat all security-relevant validation. The backend’s five-second timeout is the primary limit; the browser-side timeout prevents the interface from waiting indefinitely if the API stops responding.

import { useState } from "react";

const initialResult = { kind: "idle" };

export default function StatusChecker() {
  const [url, setUrl] = useState("");
  const [result, setResult] = useState(initialResult);

  async function handleSubmit(event) {
    event.preventDefault();
    setResult({ kind: "loading" });

    let parsed;
    try {
      parsed = new URL(url.trim());
    } catch {
      setResult({ kind: "error", message: "Enter a valid URL, including https://." });
      return;
    }

    if (parsed.protocol !== "https:" || parsed.username || parsed.password) {
      setResult({ kind: "error", message: "Enter an HTTPS URL without embedded credentials." });
      return;
    }

    const controller = new AbortController();
    const timer = window.setTimeout(() => controller.abort(), 8000);

    try {
      const response = await fetch("/api/check", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url: parsed.href }),
        signal: controller.signal,
      });

      const data = await response.json().catch(() => null);
      if (!response.ok) {
        setResult({
          kind: "error",
          message: data?.error || `The checker API returned HTTP ${response.status}.`,
        });
        return;
      }

      setResult({ kind: "complete", data });
    } catch (error) {
      setResult({
        kind: "error",
        message:
          error.name === "AbortError"
            ? "The check took too long. Try again later."
            : "Could not reach the checker service. Check your connection and try again.",
      });
    } finally {
      window.clearTimeout(timer);
    }
  }

  return (
    <main>
      <h1>Website status checker</h1>
      <p>Check an approved HTTPS endpoint.</p>

      <form onSubmit={handleSubmit}>
        <label htmlFor="site-url">Website URL</label>
        <input
          id="site-url"
          type="url"
          inputMode="url"
          autoComplete="url"
          placeholder="https://example.com"
          value={url}
          onChange={(event) => setUrl(event.target.value)}
          required
        />
        <button type="submit" disabled={result.kind === "loading"}>
          {result.kind === "loading" ? "Checking…" : "Check status"}
        </button>
      </form>

      <section aria-live="polite" aria-atomic="true">
        {result.kind === "loading" && <p role="status">Checking the website…</p>}
        {result.kind === "error" && <p role="alert">{result.message}</p>}
        {result.kind === "complete" && (
          <div>
            <h2>Check result</h2>
            <p>HTTP status: {result.data.status} {result.data.statusText}</p>
            <p>Response time: {result.data.durationMs} ms</p>
            <p>Checked: {new Date(result.data.checkedAt).toLocaleString()}</p>
          </div>
        )}
      </section>
    </main>
  );
}

The aria-live region announces result changes to assistive technology, and the button label makes the loading state visible. Keep error messages useful without showing raw backend exceptions, request headers, or sensitive target details.

Add a restricted backend endpoint

This illustrative Express endpoint checks only hostnames configured in APPROVED_HOSTS, a comma-separated environment variable. It uses HEAD to request headers without downloading a response body, times out after five seconds, and does not follow redirects. A 405 Method Not Allowed response can mean the host does not support HEAD; it is not proof that the site is down.

import express from "express";

const app = express();
app.use(express.json({ limit: "2kb" }));

const approvedHosts = new Set(
  (process.env.APPROVED_HOSTS || "")
    .split(",")
    .map((host) => host.trim().toLowerCase())
    .filter(Boolean),
);

app.post("/api/check", async (req, res) => {
  const input = req.body?.url;
  if (typeof input !== "string" || input.length > 2048) {
    return res.status(400).json({ error: "Provide a URL no longer than 2048 characters." });
  }

  let target;
  try {
    target = new URL(input);
  } catch {
    return res.status(400).json({ error: "The URL is invalid." });
  }

  if (
    target.protocol !== "https:" ||
    target.username ||
    target.password ||
    target.port !== "" ||
    !approvedHosts.has(target.hostname.toLowerCase())
  ) {
    return res.status(400).json({ error: "This HTTPS host is not approved for checks." });
  }

  const startedAt = Date.now();

  try {
    const response = await fetch(target, {
      method: "HEAD",
      redirect: "manual",
      signal: AbortSignal.timeout(5000),
      headers: { Accept: "*/*" },
    });

    return res.json({
      status: response.status,
      statusText: response.statusText,
      durationMs: Date.now() - startedAt,
      checkedAt: new Date().toISOString(),
    });
  } catch (error) {
    if (error.name === "TimeoutError") {
      return res.status(504).json({ error: "The check timed out." });
    }
    return res.status(502).json({ error: "The target could not be checked." });
  }
});

app.listen(3000);

Set APPROVED_HOSTS to hostnames you are authorized to check, for example status.example.com,docs.example.com. Do not expose this sample as an unrestricted public URL-fetching service. Even with an allowlist, deploy appropriate network egress controls and review DNS resolution behavior for your environment. If you expand the feature, you will need a carefully designed policy for destination IP ranges, DNS rebinding, redirects, ports, request volume, and abuse prevention. Do not follow a redirect automatically without validating its destination under the same policy.

The sample reports the HTTP response status even when it is not a 2xx response; fetch does not treat every non-2xx status as a network exception. A timeout is returned as 504, while other request failures are returned as 502. Avoid returning raw exception messages to users: they can expose implementation details and are not a reliable explanation of the failure.

Interpret the result carefully

An HTTP status is the result of a particular method and URL at a particular time. It is not a universal up-or-down verdict.

ResultA useful interpretation
2xxThe endpoint responded successfully to this request.
3xxThe endpoint returned a redirect. This example reports the redirect status but does not follow it.
4xxThe server responded, but the request was rejected or the resource may require different access or a different method.
5xxThe server or an upstream service reported an error.
Timeout or connection failureThe checker did not obtain an HTTP response in time, or could not connect. This does not establish why.

A 401 or 403 can indicate an access restriction, not an outage. A 404 means the requested path was not found, not necessarily that the host is unavailable. Some sites block automated checks, require authentication, or respond differently to HEAD than to GET. Display the numeric status and a cautious explanation instead of reducing every result to “online” or “offline.”

CORS, timeouts, and error states

Why the browser cannot check every site directly

A browser page can generally send some cross-origin requests, but the same-origin policy and CORS determine whether JavaScript can read the response. The target site must permit the relevant cross-origin access for browser code to inspect it. A failed browser fetch may therefore mean the response was blocked from JavaScript, not that the target is unreachable.

CORS is a browser access-control mechanism, not a server-side security boundary. Moving the request to a backend avoids the browser’s CORS restriction, but it creates the SSRF and resource-consumption concerns described above. Do not solve CORS by adding an unrestricted proxy.

Keep loading and failures distinct

Useful interface states include:

  • Idle: prompt the visitor to enter a URL.
  • Loading: disable repeated submissions and show that a check is in progress.
  • Complete: show the HTTP status, elapsed time, and check time.
  • Error: explain whether the input was rejected, the checker API failed, or the check timed out.

Use a request timeout on the server and a longer client timeout as a fallback. The client timeout only stops waiting in the browser; it does not guarantee that backend work has stopped. The server must enforce its own deadline and resource limits.

Common mistakes

  • Checking arbitrary URLs from the browser and calling every failure an outage. CORS can prevent JavaScript from reading a response. Use a backend for checks, and describe browser-side limitations accurately.
  • Treating a URL parser as a security filter. Parsing confirms syntax and exposes components; it does not prove that a destination is safe to fetch.
  • Allowing redirects without checking them. A permitted host can redirect to a different destination. This example records a redirect response without following it.
  • Assuming HEAD matches a normal visit. Some servers do not implement HEAD or handle it differently. Present that limitation, or design and secure a carefully bounded fallback.
  • Calling any non-2xx result a network failure. An HTTP error status still means an HTTP response was received. Keep response status separate from connection and timeout errors.
  • Returning backend exception details to the UI. Give users a brief message and retain only the operational logging needed by your service, with care not to log full URLs that may contain sensitive query parameters.
  • Letting users trigger unlimited checks. Apply rate limits, concurrency limits, request-size limits, and monitoring appropriate to your deployment.

Practical development workflow

  1. Build the React form and implement idle, loading, complete, and error states.
  2. Create a same-origin API route and restrict it to a small set of approved HTTPS hosts.
  3. Add a server-side timeout and a deliberate redirect policy.
  4. Test success responses, redirects, 404, 405, 500, invalid input, unapproved hosts, and slow or unreachable targets in a controlled environment.
  5. Confirm that the UI distinguishes HTTP responses from API failures and timeouts.
  6. Before supporting user-selected arbitrary destinations, review the SSRF threat model and add network-level protections, DNS and IP validation strategy, rate limits, and abuse monitoring.

Conclusion

A useful React status checker is more than a URL field and a green badge. Keep the interface responsible for interaction and clear states, and let a carefully controlled backend make the request. Interpret HTTP statuses in context, enforce timeouts on the server, and do not expose an unrestricted URL-fetching endpoint. These choices make the project both more informative to users and safer to extend.