Build a Simple Website Checker in Next.js: Status, HTTPS, Meta Tags and Response Time
When a small business owner asks "is my website okay?", they usually mean a handful of concrete things. Does it load? Is it on HTTPS? Does it have a real title and description for Google? Does it work on a phone? Is it s
When a small business owner asks "is my website okay?", they usually mean a handful of concrete things. Does it load? Is it on HTTPS? Does it have a real title and description for Google? Does it work on a phone? Is it slow? You can answer most of those questions with a small server-side checker, and Next.js route handlers are a comfortable place to build one.
This post walks through a minimal website checker built with the Next.js App Router. It fetches a URL on the server, follows redirects, measures the response time, and parses the HTML for the SEO basics that matter most for local businesses. It is not a replacement for Lighthouse or a full crawler, but it is a useful first pass, and it is the same kind of quick triage that sits behind our free website checker tools.
What the checker should report
Before writing code, decide what "healthy" means. For a typical local service site (a plumber, a dentist, a law office), these checks catch most of the problems we run into:
- Final status code. A 200 after redirects is good. A 404, 500, or a redirect loop is not.
-
HTTPS. The final URL should use
https://, and plainhttp://should redirect to it. -
Redirect chain length. One hop from
httptohttpsor from the bare domain towwwis normal. Several hops waste time. - Response time. Time to receive the full HTML document from the server. This is not the same as a Core Web Vitals score, but a slow document is a red flag.
- Title tag. Present, not empty, and not something like "Home".
- Meta description. Present and readable.
- Viewport meta tag. Without it, phones render the desktop layout zoomed out.
- Canonical tag. Helps avoid duplicate URL confusion.
- H1 count. Exactly one clear H1 is the usual target.
-
Noindex. A leftover
noindexfrom a staging site is one of the most damaging and most common mistakes.
Project setup
Any recent Next.js App Router project works. The only extra dependency is a small HTML parser. cheerio is a good fit because it is fast and has a familiar jQuery style API.
npx create-next-app@latest site-checker
cd site-checker
npm install cheerio
The route handler
Create app/api/check/route.ts. The handler accepts a URL as a query parameter, validates it, fetches it with a timeout, and returns a JSON report.
import { NextResponse } from "next/server";
import * as cheerio from "cheerio";
export const runtime = "nodejs";
const TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 5;
function normalizeUrl(input: string): URL | null {
try {
const withScheme = /^https?:\/\//i.test(input) ? input : `https://${input}`;
const url = new URL(withScheme);
if (!["http:", "https:"].includes(url.protocol)) return null;
return url;
} catch {
return null;
}
}
Next, follow redirects by hand. The built-in fetch can follow them automatically, but doing it manually lets you record every hop, which is exactly what you want to show the user.
async function fetchWithChain(start: URL) {
const chain: { url: string; status: number }[] = [];
let current = start.toString();
for (let i = 0; i <= MAX_REDIRECTS; i++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const res = await fetch(current, {
redirect: "manual",
signal: controller.signal,
headers: { "user-agent": "SiteCheckerBot/1.0" },
});
clearTimeout(timer);
chain.push({ url: current, status: res.status });
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
current = new URL(location, current).toString();
continue;
}
return { res, chain, finalUrl: current };
}
throw new Error("Too many redirects");
}
Parsing the HTML
Once you have the final response, read the body and pull out the tags you care about.
function analyzeHtml(html: string) {
const $ = cheerio.load(html);
const title = $("head > title").first().text().trim();
const description = $('meta[name="description"]').attr("content")?.trim() ?? "";
const viewport = $('meta[name="viewport"]').attr("content") ?? "";
const canonical = $('link[rel="canonical"]').attr("href") ?? "";
const robots = $('meta[name="robots"]').attr("content")?.toLowerCase() ?? "";
const h1Count = $("h1").length;
return {
title,
description,
hasViewport: viewport.includes("width=device-width"),
canonical,
noindex: robots.includes("noindex"),
h1Count,
};
}
Putting the handler together
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const target = normalizeUrl(searchParams.get("url") ?? "");
if (!target) {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
}
const started = performance.now();
try {
const { res, chain, finalUrl } = await fetchWithChain(target);
const html = await res.text();
const elapsedMs = Math.round(performance.now() - started);
const page = analyzeHtml(html);
return NextResponse.json({
finalUrl,
finalStatus: res.status,
https: finalUrl.startsWith("https://"),
redirects: chain.length - 1,
chain,
elapsedMs,
xRobotsTag: res.headers.get("x-robots-tag"),
...page,
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Request failed" },
{ status: 502 }
);
}
}
Note the x-robots-tag header. Some hosting setups send noindex as a response header instead of a meta tag, so it is worth checking both places.
Turning raw data into findings
A JSON blob is useful for developers, but business owners need plain language. A small rules function converts the report into a list of findings with a severity.
type Finding = { level: "error" | "warning" | "ok"; message: string };
export function toFindings(r: any): Finding[] {
const f: Finding[] = [];
if (r.finalStatus !== 200) f.push({ level: "error", message: `Page returned status ${r.finalStatus}.` });
if (!r.https) f.push({ level: "error", message: "The site does not end up on HTTPS." });
if (r.redirects > 2) f.push({ level: "warning", message: `The page goes through ${r.redirects} redirects before loading.` });
if (r.noindex || r.xRobotsTag?.includes("noindex")) f.push({ level: "error", message: "The page tells search engines not to index it." });
if (!r.title) f.push({ level: "error", message: "The page has no title tag." });
if (!r.description) f.push({ level: "warning", message: "The page has no meta description." });
if (!r.hasViewport) f.push({ level: "error", message: "The page has no mobile viewport tag." });
if (r.h1Count !== 1) f.push({ level: "warning", message: `The page has ${r.h1Count} H1 headings instead of one.` });
if (!r.canonical) f.push({ level: "warning", message: "The page has no canonical tag." });
if (f.length === 0) f.push({ level: "ok", message: "No basic issues found." });
return f;
}
Keep the thresholds conservative and explain them in the UI. A checker that flags everything as critical trains people to ignore it.
A minimal UI
A client component with a single input is enough to start.
"use client";
import { useState } from "react";
export default function Checker() {
const [url, setUrl] = useState("");
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
async function run(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const res = await fetch(`/api/check?url=${encodeURIComponent(url)}`);
setData(await res.json());
setLoading(false);
}
return (
<form onSubmit={run}>
<label htmlFor="url">Website address</label>
<input id="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="example.com" />
<button disabled={loading}>{loading ? "Checking..." : "Check site"}</button>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</form>
);
}
In a real product you would render the findings list with clear labels, but this is enough to test the backend.
Safety: do not build an open proxy by accident
Any endpoint that fetches arbitrary URLs on the server needs guardrails. Without them, someone can use your checker to hit internal services or hammer third party sites.
-
Block private and local addresses. Resolve the hostname with
dns.lookupand reject loopback, link-local, and private ranges before fetching. Check again after each redirect, because a public URL can redirect to an internal one. - Rate limit. A simple per-IP limit with a key value store is usually enough.
- Cap the response size. Stop reading the body after a reasonable limit so a huge file cannot tie up your function.
-
Set a timeout. The
AbortControllerabove already does this. - Identify your bot. A clear user agent string is polite and makes debugging easier for site owners.
What this checker does not cover
Being honest about limits makes the tool more trustworthy. This approach does not run JavaScript, so it will miss content rendered only on the client. It does not measure Core Web Vitals, which depend on real browsers and real devices. It checks one page, not the whole site. For those, pair it with Lighthouse, PageSpeed Insights, and Search Console data.
It also does not judge the things that actually win local customers: whether the phone number is easy to tap, whether the service area is clear, and whether the Google Business Profile matches the site. Those need a human review, which is why we offer a free website audit alongside the automated checks.
Where to go next
Some useful extensions once the basics work:
- Check
robots.txtandsitemap.xmland confirm the sitemap URLs return 200. - Look for
LocalBusinessstructured data and validate the required fields. - Detect mixed content by scanning for
http://asset URLs on an HTTPS page. - Store results so users can rerun a check after a fix and compare.
If you are building sites for local businesses, a small checker like this becomes a habit: run it before launch, run it after every migration, and run it whenever a client says leads dropped. If you want help building something larger on Next.js, our team does Next.js development for businesses that need fast, search friendly sites.
Thanks for reading.
CodeHunterz Team
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.