A Practical SEO Guide, Part 1: Make It Crawlable First

Most "SEO tips" articles start with keywords and meta tags. That's backwards. If a search engine can't reliably read your content in the first place, no amount of keyword tuning matters. So this series starts where the crawler starts — and only later gets to the shiny stuff (structured data, social cards, Core Web Vitals).

I recently did a full SEO pass on a portfolio site (cv.lans.cloud, a Next.js App Router app), so I'll use real examples from that. But everything here applies to any stack — Astro, Rails, Django, plain HTML.

The one question that matters most

Open your site and run this:

curl -s https://your-site.com/ | grep -o '<h1[^>]*>[^<]*' 

If your actual headline text comes back, good. If you get an empty <div id="root"> and nothing else, you have the single most common and most damaging SEO problem: your content only exists after JavaScript runs.

Googlebot can execute JavaScript, but it does so on a delay, inconsistently, and with a rendering budget. Every other crawler and social scraper (Bing, LinkedIn, Slack, the X card fetcher) mostly won't. If your content isn't in the initial HTML response, you're betting your discoverability on the least reliable path.

The fix is server-side rendering (SSR) or static generation (SSG). On the portfolio site, all the real content — experience, skills, projects — is fetched in async Server Components, so it's in the HTML before a single line of client JS executes:

// A React Server Component: the data is fetched and rendered on the server.
export async function ExperienceTimeline() {
  const experiences = await fetchExperiences(); // runs server-side
  return <section id="experience">{/* real text, in the HTML */}</section>;
}

You can confirm it worked the same way the crawler will — by looking at raw HTML, not the rendered DOM in devtools (devtools shows you the post-JavaScript tree, which hides this exact bug):

curl -s https://cv.lans.cloud/ | grep -c 'Senior Software Engineer'
# >0 means the headline text ships in the HTML

Semantic HTML is machine-readable structure

Crawlers infer meaning from your tags. A page built out of <div> soup forces them to guess; semantic elements tell them outright. You don't need to be a purist — you need the load-bearing ones:

  • <main> — the primary content, one per page.
  • <article> / <section> — self-contained blocks with a heading.
  • <nav>, <header>, <footer> — chrome the crawler can discount.
  • <a href> for navigation — real anchor tags, not <div onClick>. Crawlers follow href; they don't click JS handlers. This is how your internal link graph gets discovered.

Get the heading hierarchy right

Headings are the outline a crawler builds of your page. Two rules cover 90% of it:

  1. Exactly one <h1> per page, and it should be the page's actual subject. On the portfolio that's the person's name; on a blog post it's the title.
  2. Don't skip levels. <h1><h2><h3>, not <h1><h4> because <h4> looked the right size. Style with CSS, structure with tags.

A sneaky one worth checking: many blog templates render the post title as an <h1> and the markdown body starts with # Title — giving you two <h1>s on every page. Audit it:

curl -s https://your-site.com/some-page | grep -o '<h1' | wc -l
# should print 1

What "crawlable" gets you

None of this is glamorous, and none of it shows up in a Lighthouse score as a single number. But it's the foundation the rest of the series builds on:

  • Content in the HTML → the page can be indexed at all.
  • Semantic structure + real links → the crawler understands and traverses your site.
  • Clean headings → your page earns rich, well-structured snippets instead of a mangled excerpt.

Get this right and you've done the hard 80%. In Part 2 we hand the crawler a map — robots.txt, sitemap.xml, and canonical URLs — so it doesn't have to find everything by luck.


Next: Part 2: The Discovery Layer →