Part 2 of a series on practical SEO for developers where i cover How to get your site indexed, On-Page SEO, and Controling Your Search Appearance
In the first post, we covered how to get Google to find your site — verification, sitemaps, indexing requests, and the robots.txt file that can quietly break everything if you’re not careful. If you followed along, your site is now technically crawlable and you’ve given Google a map of your URLs.
But getting crawled and getting ranked are two different problems. When Googlebot visits one of your pages, it’s not just downloading the HTML — it’s trying to answer a question: what is this page about, and is it worth showing to someone who’s searching for that topic? The signals it uses to answer that question are mostly things you’ve already heard of — title tags, meta descriptions, headings — but the details of how they work, and how they can go wrong, are rarely explained clearly.
This post goes through the on-page signals that actually matter, how to implement them correctly in a Next.js and Django stack, and a few gotchas that are easy to miss.
The Title Tag: Your Most Important On-Page Signal
The <title> tag is the single most important piece of on-page SEO you control. It appears in three places: the browser tab, the search result listing (as the blue clickable link), and when someone shares your page on most platforms. Getting it right matters both for ranking and for click-through rate.
A well-formed title has two jobs. First, it signals to Google what the page is about by including the primary keyword or topic near the front. Google gives more weight to words that appear earlier in the title, so “Django Database Migration: Moving from Aiven to NeonDB” will perform better for migration-related searches than “My Experience with NeonDB, Including a Full Django Migration Guide.” Second, it gives a human reader enough context to decide whether to click — which means it should be specific, not clever.
On length: somewhere between 50 and 60 characters is the practical target. Search results truncate titles that run too long, which means a reader might see “Django Database Migration: Moving from Aiven to NeonDB w…” and never know what the sentence was going to say. That’s not catastrophic, but it’s a wasted opportunity. Under 50 characters and you’re probably not being specific enough.
A pattern that works consistently for technical content is: Primary Topic: Specific Angle | Site Name. The primary topic catches the search query, the specific angle differentiates your result from the ten others on the page, and the site name (at the end, not the front) is secondary branding that doesn’t crowd out the content signal.
In Next.js App Router, you handle this through the metadata export, which Next.js uses to render all your head tags:
// For a static page like /about
// app/about/page.tsx
export const metadata: Metadata = {
title: "About | Your Blog Name",
// ...
};
For dynamic pages like individual blog posts, you use generateMetadata, which can fetch data asynchronously to build the title from your actual content:
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
// Fetch the post from your Django API
const post = await fetch(
`https://your-django-api.com/api/posts/${params.slug}/`
).then((res) => res.json());
return {
// The post's own title, followed by the site name
title: `${post.title} | Your Blog Name`,
};
}
One subtle but useful feature: you can define a title template in your root layout’s metadata that automatically appends your site name to any page-specific title, so you only have to define the pattern once:
// app/layout.tsx
export const metadata: Metadata = {
title: {
// Individual pages provide the %s value
template: "%s | Your Blog Name",
// This is used if a page doesn't set its own title
default: "Your Blog Name",
},
};
Then your individual pages only need to export the page-specific part — Next.js handles the rest. Clean, consistent, and easy to update across the whole site from one place.
Meta Descriptions: Not a Ranking Factor, But Still Critical
Here’s something that surprises a lot of developers: the meta description has essentially no direct effect on how Google ranks your page. Google confirmed this years ago and nothing has changed since. So why bother writing one?
Because it determines whether people click on your result.
When your page appears in search results, Google shows the title and below it a short snippet of text. If you’ve written a meta description, Google might use it as that snippet. If you haven’t, Google will pull a random excerpt from your page — usually something like a navigation link, an image caption, or the first sentence of a section that makes no sense without context. The difference between a well-written description and a randomly chosen excerpt can be dramatic in terms of click-through rate, and click-through rate is a signal that influences ranking over time.
The practical target is around 150–160 characters. Any longer and search results will cut it off with an ellipsis. Write it like you’re explaining the page to a smart person who has ten seconds to decide whether to click. For a blog post, that means something like: “Here’s how I migrated a Django Postgres database from Aiven to NeonDB — including every error I ran into and how I fixed them.” Specific, honest about what the reader will get, and directly relevant to what they searched for.
One important caveat: even if you write a perfect meta description, Google may still override it with something from your page content that it considers more relevant to a particular search query. This is more common than people realize and is not something you can prevent. The goal is to write one good enough that Google uses it most of the time.
In Next.js, the description goes in the same metadata object alongside the title:
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await fetch(
`https://your-django-api.com/api/posts/${params.slug}/`
).then((res) => res.json());
return {
title: `${post.title} | Your Blog Name`,
// Use a dedicated SEO excerpt if available, otherwise fall back
// to a trimmed version of the post's intro paragraph
description: post.seo_description || post.excerpt?.slice(0, 155),
};
}
Storing a dedicated seo_description field on your Django Post model is worth doing. It gives you explicit control over the description without forcing you to write your content in a way that’s optimized for a 155-character excerpt.
The Keywords Tag: What It Was and What Matters Now
At some point early in the history of the web, search engines used the <meta name="keywords"> tag to understand what a page was about. Webmasters would stuff it full of terms — sometimes dozens — and it worked, until it didn’t. The tag was so heavily abused by spammers that Google stopped using it as a ranking signal around 2009. Bing and other engines followed suit. Today, including a <meta name="keywords"> tag in your HTML does essentially nothing for search engine ranking.
I mention this because the tag still appears in tutorials, CMS settings panels, and generated boilerplate everywhere, which creates a reasonable amount of confusion about whether it’s worth maintaining. It’s not — at least not for search engines. You can leave it out entirely.
But here’s where the concept gets muddled in a useful way: keywords still matter enormously for SEO, they just don’t live in a meta tag anymore. They live in your content.
The way to think about it is this: Google’s job is to match a search query (what someone typed) to a page (what you wrote). The better your page’s content reflects the language real people use when searching for your topic, the more often Google will serve your page as a result for those queries. That’s what “targeting a keyword” means in practice — it means writing content that naturally and thoroughly uses the words and phrases your target reader would search for, not cramming terms into a meta tag that nobody reads.
For a developer blog, this plays out in a few concrete ways. Your primary keyword — the topic you most want the post to rank for — should appear in your title tag, your first paragraph or two, and in at least one heading. It should feel like it belongs there, not like it was inserted for optimization. Secondary keywords are the related terms and phrases that would naturally appear in a thorough treatment of the topic. If you write an in-depth post about something, you’ll use the secondary keywords organically without thinking about them.
One distinction worth understanding is the difference between short-tail and long-tail keywords. A short-tail keyword is broad: “database migration.” Millions of pages compete for that term and most of them are from large, established sites. A long-tail keyword is more specific: “migrate Django Postgres database from Aiven to NeonDB.” Far fewer pages compete for it, the audience is smaller but more precisely matched to what you wrote, and a newer site has a realistic chance of ranking for it. For a personal blog in its early days, long-tail keywords are the practical path to organic traffic.
The honest takeaway here is that the best SEO keyword strategy for a technical writer is just to write thoroughly and specifically. A post that genuinely covers a topic in depth will naturally use the right language. You don’t need to count keyword occurrences or calculate keyword density — those are remnants of an older approach that doesn’t reflect how modern search ranking actually works.
Headings: Structure as a Signal
HTML heading tags — <h1> through <h6> — are not just visual formatting. They’re semantic markers that communicate your page’s structure to both search engines and assistive technology like screen readers. Google uses the heading hierarchy to understand how a page is organized and which sections are most important.
The rule is simple but firm: one <h1> per page, and it should be your primary topic or post title — essentially the same idea as your <title> tag, though it doesn’t have to be identical word for word. <h2> tags are your major sections, <h3> tags are subsections within those, and so on. This isn’t about aesthetics; it’s about giving Google a navigable outline of your content.
A common problem in Django + Next.js setups is that markdown-rendered content can produce unexpected heading levels. If your blog posts are stored as Markdown in Django and rendered on the frontend, the heading levels in your post body depend entirely on how the author wrote the Markdown — and it’s easy to accidentally have two <h1> tags if your post body starts with a # Title and your page template also renders an <h1> for the post title. Check your rendered output in the browser’s developer tools and search for h1 elements on any given post page. There should be exactly one.
The practical approach for a blog is to strip or convert any <h1> tags inside your post body content to <h2> at render time, and always render the post title itself as the page’s one <h1>. That way the template controls the top-level structure and the post content contributes subsections.
Beyond heading levels, it’s worth writing heading text that meaningfully describes what follows — not just “Introduction” or “Conclusion” (though those aren’t terrible), but something specific enough that a reader skimming the headings alone could understand the arc of the post. This is good writing practice that also happens to be good SEO, since Google uses heading text as part of understanding what each section is about.
Canonical URLs: Telling Google Which Version Is Official
One problem that surfaces quietly on many sites is duplicate content — the same or very similar content appearing at multiple URLs. This isn’t just about having two nearly identical pages. Google treats https://yourdomain.com/blog/my-post, https://yourdomain.com/blog/my-post/, and https://www.yourdomain.com/blog/my-post as three different URLs, even if they all return the same HTML. When Google crawls your site and finds the same content at multiple addresses, it has to decide which one to index and rank — and it might not choose the one you’d want.
The rel="canonical" link tag solves this by letting you designate an official URL for a page’s content. Any page that includes <link rel="canonical" href="https://yourdomain.com/blog/my-post"> is telling Google: “if you’ve seen this content elsewhere, this is the URL I want you to credit and rank.”
A few scenarios where this matters in practice. First, trailing slashes: if your Django API returns URLs without trailing slashes but your Next.js router sometimes adds them (or vice versa), you’ll have inconsistency. Pick one convention — preferably whatever your web server or CDN naturally serves — and set canonical URLs accordingly. Second, pagination: if your blog has a paginated index at /blog?page=2, /blog?page=3, etc., those pages share a lot of structural content with the main /blog page. Setting canonicals thoughtfully here prevents Google from getting confused. Third, if your Django backend is ever accessible directly at a different subdomain or port during development or staging, make absolutely sure canonical tags point to your production domain only.
In Next.js, the alternates.canonical field in the metadata export handles this:
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await fetch(
`https://your-django-api.com/api/posts/${params.slug}/`
).then((res) => res.json());
return {
title: `${post.title} | Your Blog Name`,
description: post.seo_description || post.excerpt?.slice(0, 155),
alternates: {
// Explicitly declare the one true URL for this post
canonical: `https://yourdomain.com/blog/${params.slug}`,
},
};
}
For Django-rendered pages, you’d add the canonical tag to your base template using a context variable, which lets individual views override it as needed:
# In your view
def post_detail(request, slug):
post = get_object_or_404(Post, slug=slug, status="published")
return render(request, "blog/post_detail.html", {
"post": post,
# Construct the canonical URL explicitly
"canonical_url": f"https://yourdomain.com/blog/{slug}",
})
<!-- In your base template -->
{% if canonical_url %}
<link rel="canonical" href="{{ canonical_url }}">
{% endif %}
The key principle with canonicals is to be explicit everywhere rather than relying on Google to infer the right URL. It’s a small amount of implementation work that prevents a category of indexing confusion entirely.
Putting It Together: What a Well-Configured Page Looks Like
To make this concrete, here’s what the full metadata output for a single blog post should look like once everything in this post is implemented:
<head>
<!-- The title: keyword-first, site name at the end, under 60 chars -->
<title>Django to NeonDB Migration: Lessons Learned | Your Blog</title>
<!-- Meta description: specific, readable, under 155 chars -->
<meta name="description" content="How I migrated a Django Postgres database from Aiven to NeonDB's free tier — including the errors I hit and exactly how I fixed each one.">
<!-- Canonical: the one true URL for this content, no trailing slash ambiguity -->
<link rel="canonical" href="https://yourdomain.com/blog/django-neondb-migration">
<!-- No keywords meta tag — not useful, not needed -->
</head>
And the rendered page body should have one <h1> that matches the post title, followed by <h2> and <h3> tags that outline the content structure, with the post’s primary topic naturally present in the first paragraph or two.
None of this is complicated in isolation. What makes it tricky in practice is that these decisions interact — a title tag that’s too generic undermines everything else, and good headings can’t compensate for a canonical URL that’s inconsistent across your site. The goal is for all of these signals to point in the same direction, so that when Google reads your page, there’s no ambiguity about what it is, what it covers, and where it officially lives.
What’s Next
With your pages technically sound and clearly signalling their content to Google, the last piece of the puzzle is how Google presents you in search results. A well-ranked page doesn’t just get clicked — it gets clicked because the search result itself communicates clearly: the site name, the content type, the structure, sometimes even ratings or dates. That’s controlled by a layer most developers don’t think about until something looks wrong.
In the final post in this series, we’ll cover JSON-LD structured data, why your site name might be appearing incorrectly in Google search, and how Open Graph tags control the way your content looks when shared on social platforms.
