Technical SEO for a Laravel and Livewire portfolio: what I actually implemented
A practical checklist from building this site: per-page meta, canonical URLs, Open Graph, JSON-LD, a dynamic sitemap and Search Console — with the code and the things I deliberately skipped.
A portfolio that nobody can find is a brochure in a drawer. When I built this site I treated technical SEO as part of the feature work, not a plugin to add at the end. This article lists what I implemented in a Laravel and Livewire application, in the order that matters most.
Start with the good news: Livewire is server-rendered
A common worry is that "reactive" frameworks hide content from crawlers. Livewire renders full HTML on the server, so the first response already contains your content, headings and links. You do not need a prerender service. That removes the biggest SEO risk of many JavaScript-heavy sites.
1. A title and description for every page
Search results show the <title> and meta description, so each URL needs its own. I use one layout that reads optional variables and falls back to sensible defaults:
@php
$seoTitle = $title ?? 'Gohar Nawaz | Full Stack Engineer';
$seoDescription = $description ?? 'Full Stack Engineer building Laravel and Node.js platforms.';
@endphp
<title>{{ $seoTitle }}</title>
<meta name="description" content="{{ $seoDescription }}">
Each Livewire page then passes its own values through the layout:
return view('livewire.article-show')->layout('layouts.app', [
'title' => $article->seoTitle().' | Gohar Nawaz',
'description' => $article->seoDescription(),
]);
Keep titles under about 60 characters and descriptions under about 160, or search engines truncate them. Write for a human deciding whether to click.
2. One canonical URL per page
The same content reachable at several URLs splits its ranking signals. Add a canonical link:
<link rel="canonical" href="https://example.com/articles/my-article">
For pages with filters in the query string, such as ?tag=laravel, point the canonical at the clean listing URL so filtered views are not indexed as separate pages.
3. Open Graph and Twitter cards
When a link is pasted into LinkedIn, Slack or WhatsApp, these tags decide how it looks:
<meta property="og:title" content="...">
<meta property="og:description" content="...">
<meta property="og:image" content="https://example.com/og-image.png">
<meta name="twitter:card" content="summary_large_image">
Use an absolute image URL at 1200×630 pixels. Social networks cache aggressively, so after changing the image, ask each platform to re-scrape the page.
4. Structured data with JSON-LD
Structured data tells search engines what a page is. I use:
- Person and WebSite on every page, so the name, job title and profiles connect to one entity.
- BlogPosting on articles, with the headline, dates, author and image.
- BreadcrumbList for the Home → Articles → Article path.
$data = [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $article->title,
'datePublished' => $article->published_at->toIso8601String(),
'dateModified' => $article->updated_at->toIso8601String(),
'author' => ['@id' => url('/#person')],
];
echo '<script type="application/ld+json">'.json_encode($data, JSON_UNESCAPED_SLASHES).'</script>';
Validate pages with Google's Rich Results Test. Only mark up what is actually visible on the page.
5. A dynamic sitemap
A static sitemap.xml goes stale the moment you publish something new. Generate it from the database instead:
Route::get('/sitemap.xml', SitemapController::class);
The controller lists the homepage, case studies, the articles index and every published article with its updated_at as lastmod. Cache the result for a few minutes. Also delete any leftover static file from public/ — a real file in that folder is served before Laravel's route ever runs.
6. robots.txt
Keep it short and explicit: block the admin area and private form pages, and point to the sitemap.
User-agent: *
Disallow: /admin
Disallow: /leave-a-review
Sitemap: https://example.com/sitemap.xml
robots.txt stops crawling, not indexing. For pages that must never appear in results, also send noindex — I do this for draft previews and the admin.
7. Content structure
- One
<h1>per page, and<h2>/<h3>in a logical order. The article table of contents is generated from them. - Link related pages to each other. Internal links help crawlers discover pages and tell them what each page is about — for instance, this article connects to the hosting setup and the Terraform workflow.
- Descriptive link text, not "click here".
8. Images
Give every meaningful image alt text, explicit width and height (to avoid layout shift), and loading="lazy" below the fold. Serve modern formats such as WebP. I resize and convert uploads in the browser before they reach the server.
9. Speed and Core Web Vitals
Google uses real-user page experience as a signal. The cheapest wins on this site: no render-blocking third-party scripts, self-hosted fonts, a CSS-only background instead of an image, and loading the syntax highlighter only on pages that contain code.
10. Tell Google you exist
- Verify the domain in Google Search Console.
- Submit the sitemap.
- Use URL Inspection → Request indexing for important new pages.
Be patient: new pages commonly take days or weeks to appear and longer to rank. Publishing useful, specific content regularly matters more than any single tag.
What I deliberately skipped
- Keyword stuffing and meta keywords — ignored by search engines and bad for readers.
- Buying links — a quick way to hurt a young site.
- Chasing a perfect score — fix what is measurably wrong, then go back to writing.
Checklist
| Item | Done when |
|---|---|
| Unique title and description | Every URL has its own |
| Canonical | Present, filters point to the clean URL |
| Open Graph | Absolute 1200×630 image |
| JSON-LD | Validates in the Rich Results Test |
| Sitemap | Dynamic, submitted in Search Console |
| robots.txt | Blocks admin, links the sitemap |
| Images | Alt text, dimensions, lazy loading |
| Internal links | Related pages link to each other |
More articles
Running a Laravel queue worker in Docker Compose without migration races
Put your queue worker in its own container, make it wait for a healthy database, stop it from re-running migrations, and restart it cleanly on every deploy.
· 4 min read
Laravel CI/CD with GitHub Actions and a self-hosted runner on EC2
Run tests on GitHub, then deploy from a runner that lives on the server itself — no SSH keys in secrets, no inbound deploy port, and a deploy that never gets cancelled halfway.
· 4 min read
Hosting a Laravel app on one AWS EC2 instance with Docker, Caddy and Cloudflare
The exact setup behind this portfolio: Docker Compose on a single EC2 instance, Caddy for HTTPS, Cloudflare in front, and a security group that only lets Cloudflare in.
· 4 min read