If you write front-end code, you make most of the technical SEO decisions on a site whether anyone calls them that or not. Status codes, routing, rendering, the <head>, links and page speed all live in your code. This guide is written for developers who would rather build than read SEO blogs: what to implement, why, and how to check it, with a small Next.js app I built and tested to show each piece.
It replaces two older posts on this site about front-end developers and SEO. They overlapped, and parts had aged badly (Google+ Authorship and the Mobile-Friendly Test no longer exist), so they are now one guide.
The Mental Model: Crawl, Render, Index, Serve
Google discovers a URL, fetches it, renders it with a recent version of Chrome, analyzes the result, picks a canonical version among duplicates, stores it, and later decides whether to show it for a query. How Google Search works explains each stage. As a developer you mostly control the first three: can the URL be fetched, does it return the right status, and does the rendered HTML contain the content and links.
Keyword research, content and earning links are mostly not your job. Making sure good content can be found and loads fast is.
The Checklist
| Area | What to implement | How to check |
|---|---|---|
| Head | Unique <title> and meta description per page, lang on <html> | View source; Lighthouse SEO audit |
| Links | <a href> for navigation; History API routing, no # routes | Inspect rendered HTML |
| Rendering | Main content and links in server-rendered or prerendered HTML | curl the page; URL Inspection in Search Console |
| Status codes | 200 for pages, real 404/410 for missing ones, 301/308 for moves | curl -I |
| Indexing control | noindex via meta tag or X-Robots-Tag; robots.txt only to manage crawling | curl -I; robots.txt report in Search Console |
| Duplicates | Self-referencing absolute canonical URLs | View source |
| Sitemap | Absolute URLs, accurate lastmod | Sitemaps report in Search Console |
| Structured data | JSON-LD matching visible content | Rich Results Test |
| Performance | Good LCP, INP and CLS | PageSpeed Insights; Chrome DevTools |
| Mobile | Responsive layout, same content and metadata as desktop | DevTools device mode; Lighthouse |
1. Get the Head and Semantics Right
Each page needs its own accurate <title>, which Google says strongly influences the title link in results, and a short meta description. Use one <h1>, landmark elements such as <main> and <nav>, and a lang attribute. Google’s SEO Starter Guide says heading order does not affect ranking, but it matters for screen readers, and accessible markup and crawlable markup overlap a lot. Google also ignores the meta keywords tag, so skip it.
To show how little it takes, I ran Lighthouse 13.5 on two tiny pages. The “bad” one used <div>s for everything:
<html><body>
<div class="title">My Shop</div>
<div onclick="location='/products'">Products</div>
<img src="hero.png">
<a href="#" onclick="go()">click here</a>
<p style="font-size:9px">Welcome to the shop. We sell things.</p>
</body></html>The fixed one added a doctype, lang, viewport, title, description, canonical, <nav> with real links, <main>, an <h1> and alt text with image dimensions. Results:
| Lighthouse category | Bad page | Fixed page |
|---|---|---|
| SEO | 73 | 100 |
| Accessibility | 53 | 100 |
The five failures were a missing title, missing lang, an image without alt text, no main landmark and no meta description. A perfect score only means Lighthouse’s checks passed; it is a smoke test, not a ranking prediction.
2. Use Real Links and Real URLs
Google generally follows only <a> elements with an href. Its link guidance lists patterns it cannot follow: <span href>, <a onclick> without an href, framework attributes such as routerLink that do not render an href, and javascript: URLs. Router components like Next.js <Link> are fine because they render a normal <a href>.
Use descriptive link text rather than “click here”, and remember that for an image link the alt text acts as the anchor text. For client-side routing, use the History API rather than hash fragments so every view has a crawlable URL.
3. Render the Content Google Needs
Google renders JavaScript, but rendering happens after crawling and can be delayed. Its JavaScript SEO basics still recommend server-side rendering or prerendering where you can. Practical rules:
- Put primary content and navigation links in the server-rendered HTML for content pages.
curlthe URL: if the text is not there, you depend on rendering. - Do not block JavaScript or CSS files in robots.txt; Google needs them to render the page.
- Do not ship a page with
noindexand plan to remove it with JavaScript. Google may skip rendering pages that start withnoindex, so the removal may never be seen. - Fingerprint asset filenames (for example
app.3f9a2c.js) so Google does not render with stale scripts.
4. Return Honest Status Codes
Status codes are the most developer-owned part of SEO. Google’s redirect documentation treats them like this:
| Situation | Use | What Google does |
|---|---|---|
| URL moved for good | 301 or 308 | Treats the target as canonical |
| Temporary move or maintenance | 302, 303 or 307 | Keeps the original URL in results |
| Page gone | 404 or 410 | Drops it over time |
| No server access | Instant meta refresh, then JavaScript redirect | Works, but JavaScript redirects are the least reliable |
Single-page apps have a classic bug here: a “Not found” view rendered with a 200 status, which Google calls a soft 404. Google’s JavaScript guidance says to redirect to a URL that returns a real 404 or add a noindex to error views. Frameworks with server rendering avoid it if you use their not-found mechanism; in my Next.js test, calling notFound() returned HTTP 404 and added <meta name="robots" content="noindex"> automatically.
5. Control Crawling and Indexing (Without Mixing Them Up)
These two are easy to confuse, and I confused them while building the example for this post:
- robots.txt controls crawling. Google says it is not a way to keep a page out of Google. A blocked URL can still be indexed without its content if other pages link to it.
noindexcontrols indexing. Use<meta name="robots" content="noindex">or, for non-HTML files such as PDFs, anX-Robots-Tag: noindexresponse header.
The catch: noindex only works if Google can crawl the page. My first version of the example disallowed /cart in robots.txt and also sent X-Robots-Tag: noindex for it. That combination means Googlebot never fetches the page, never sees the noindex, and could still list the URL. I removed the robots.txt rule and kept the header. Use robots.txt for crawl-heavy areas you never want fetched, such as API endpoints or infinite filter combinations, and noindex for pages that may be fetched but should not appear in results.
6. Declare Canonical URLs
The same content often lives at several URLs: tracking parameters, sort orders, trailing slashes, http vs https, www vs non-www. Google’s canonicalization guidance ranks the signals: permanent redirects and rel="canonical" are strong, sitemap inclusion is weak. Use absolute URLs, include a self-referencing canonical on the canonical page, and never use robots.txt or noindex to pick canonicals. Canonicals are hints, so keep internal links, sitemaps and redirects consistent with them.
7. Generate a Sitemap
A sitemap lists the URLs you want indexed. From Google’s sitemap docs: up to 50,000 URLs or 50 MB uncompressed per file (use a sitemap index beyond that), absolute URLs, UTF-8. Google ignores priority and changefreq, and only trusts lastmod if it is consistently accurate, meaning it changes when the main content changes, not on every deploy. Reference the sitemap from robots.txt and submit it in Search Console.
8. Add Structured Data as JSON-LD
Structured data describes a page in a machine-readable vocabulary (schema.org) and can make it eligible for rich results. Google recommends JSON-LD, requires the markup to describe content users can see, and does not guarantee rich results even when markup is valid. Test with the Rich Results Test before shipping. When injecting JSON-LD through a framework, escape < so a value can never close the script tag, as the example below does.
9. Images and Lazy Loading
Give meaningful images descriptive alt text and explicit width and height. Lazy-load below-the-fold images with loading="lazy", but Google’s lazy-loading guidance warns against lazy-loading above-the-fold content, and Googlebot does not scroll or click, so content must load when it enters the viewport, not on a scroll event. For infinite scroll, give each chunk a persistent URL such as ?page=12 and link the pages sequentially.
10. Core Web Vitals, From the Developer Side
Google’s ranking systems use Core Web Vitals, though Google gives speed no specific weight and puts relevance first (page experience). The thresholds, at the 75th percentile of real visits: LCP 2.5 s or less, INP 200 ms or less, CLS 0.1 or less. The fixes are code fixes:
| Metric | Most effective fixes |
|---|---|
| LCP (loading) | Make the main image discoverable in the HTML, add fetchpriority="high" to it, never lazy-load it, cut render-blocking CSS and JavaScript, reduce server response time (web.dev LCP guide) |
| INP (responsiveness) | Break up long tasks, do the visual update first and defer the rest, keep event handlers light, keep the DOM small (web.dev INP guide) |
| CLS (stability) | Set width and height or aspect-ratio on media, reserve space for ads and embeds, tune web font fallbacks, animate with transform (web.dev CLS guide) |
Smaller bundles help all three. I measured what minification saves in this test of CSS, JavaScript and HTML minifiers.
11. Mobile and International Sites
Google indexes the mobile version of pages (mobile-first indexing) and recommends responsive design. If you serve different HTML to mobile, keep the same primary content, headings, structured data, titles, descriptions and robots tags. Google retired its Mobile-Friendly Test in 2023 and points to Lighthouse instead.
For translated or regional versions, use hreflang annotations in the head, HTTP headers or the sitemap. Each version must list itself and every other version, links must be reciprocal, URLs must be absolute, and codes are language first (en-GB, not UK). Add x-default for a fallback such as a language picker.
<link rel="alternate" hreflang="en-US" href="https://example.com/us/" />
<link rel="alternate" hreflang="en-GB" href="https://example.com/uk/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />Worked Example: Next.js
Here is how the pieces look in a real framework. I created an app with create-next-app (Next.js 16.4.1, React 19.3), added the files below, ran a production build, and checked the responses with curl. Other frameworks have equivalents; the HTTP results are what matter.
Site-wide defaults
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: { default: 'My Shop', template: '%s | My Shop' },
description: 'Handmade ceramics, made to order in small batches.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Product page: metadata, canonical, JSON-LD and a real 404
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) return {};
return {
title: product.name,
description: product.description,
alternates: { canonical: `/products/${product.slug}` },
};
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
offers: { '@type': 'Offer', price: product.price, priceCurrency: 'USD', availability: 'https://schema.org/InStock' },
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
</main>
);
}robots.txt and sitemap.xml
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', disallow: '/api/' },
sitemap: 'https://example.com/sitemap.xml',
};
}import type { MetadataRoute } from 'next';
import { getProducts } from '@/lib/products';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getProducts();
return [
{ url: 'https://example.com/' },
...products.map(p => ({ url: `https://example.com/products/${p.slug}`, lastModified: p.updated })),
];
}Redirects and headers
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async redirects() {
return [{ source: '/shop/:slug', destination: '/products/:slug', permanent: true }];
},
async headers() {
return [{ source: '/cart', headers: [{ key: 'X-Robots-Tag', value: 'noindex' }] }];
},
};
export default nextConfig;What the server returned
$ curl -s localhost:3456/products/blue-mug (relevant lines)
<html lang="en">
<title>Blue Ceramic Mug | My Shop</title>
<meta name="description" content="A hand-thrown blue ceramic mug, made to order."/>
<link rel="canonical" href="https://example.com/products/blue-mug"/>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Product",...}</script>
$ curl -s -o /dev/null -w "%{http_code}" localhost:3456/products/nope
404
$ curl -sI localhost:3456/shop/blue-mug
HTTP/1.1 308 Permanent Redirect
location: /products/blue-mug
$ curl -sI localhost:3456/cart
HTTP/1.1 200 OK
X-Robots-Tag: noindex
$ curl -s localhost:3456/robots.txt
User-Agent: *
Disallow: /api/
Sitemap: https://example.com/sitemap.xmlThings to notice: metadataBase turned the relative canonical into an absolute URL; permanent: true produced a 308, which Google treats like a 301; the missing product returned a real 404 with noindex; and the sitemap included lastmod only where I supplied a real date. The sitemap was well-formed XML according to xmllint. I did not deploy it or run it through Search Console, since example.com is not my site.
Tools to Check Your Work
curl -Iand view-source: status codes, headers, and what is in the HTML before JavaScript runs.- Lighthouse in Chrome DevTools or the command line: basic SEO, accessibility and performance checks.
- PageSpeed Insights: real-user Core Web Vitals where available, plus lab data.
- Search Console URL Inspection: whether a page is indexed, the canonical Google chose, and the rendered HTML Google saw.
- Rich Results Test: structured data eligibility.
What Developers Can Leave to Others
You do not need to become an SEO specialist. Keyword research, deciding what content to write, internal linking strategy and earning links from other sites belong with whoever owns content and marketing. Your job is to make sure none of that work is wasted: every page they publish should be fetchable, render its content, return the right status, declare the right canonical and load fast.
One more correction from the original posts, since it confuses developers reading analytics: a visit from a Google result is organic traffic even when the person searched the page’s exact title. In 2021 I described that as “direct” traffic, which was wrong. Direct traffic is someone typing the URL or using a bookmark.
Conclusion
Technical SEO for developers is mostly correct web engineering: semantic HTML, real links, server-rendered content, honest status codes, clear indexing rules, canonical URLs, a sitemap, accurate structured data and fast pages. Build those into your templates once and every page inherits them. Then verify with curl, Lighthouse and Search Console instead of guessing.
FAQs
Does a front-end developer need to know SEO?
Yes, the technical part. Status codes, rendering, links, metadata, structured data and performance are decided in code, and mistakes there can stop good content being crawled or indexed. Keyword and content strategy can stay with others.
Is client-side rendering bad for SEO?
Not automatically, because Google renders JavaScript. But rendering can be delayed and some patterns break discovery, such as links without href, hash routing and soft 404s. Server-side rendering or prerendering for content pages removes most of the risk.
Should I block a page in robots.txt or use noindex?
Use noindex to keep a page out of search results, and make sure the page is not blocked in robots.txt, otherwise Google never sees the noindex. Use robots.txt to stop crawling of areas you never want fetched, such as API endpoints.
Is a 308 redirect as good as a 301 for SEO?
Google treats both 301 and 308 as permanent redirects and uses the target as the canonical URL. Next.js uses 308 for permanent redirects by default.
Does structured data improve rankings?
Google presents structured data as a way to make pages eligible for rich results and does not promise a ranking boost. Rich results are not guaranteed, and the markup must match visible content.
What replaced Google’s Mobile-Friendly Test?
Google retired it in late 2023 and points developers to Lighthouse. Chrome DevTools device mode is useful for manual checks.








