Mert Özen Further With Every Line
SEO & GEO Technical SEO — #07

Redirects: 301, 302, 307, 308 and Doing Them Right in ASP.NET Core

Mert Özen Nov 8, 2026 11 min 4 views
Redirects: 301, 302, 307, 308 and Doing Them Right in ASP.NET Core

Use 301 or 308 for permanent moves: Google treats them as a strong canonical signal and 302 and 307 as weak ones, while 308 and 307 keep POST. We look at ASP.NET Core's URL Rewriting Middleware, UseHttpsRedirection with its 307 default, single-hop 301s from a slug history in the database, chains, and redirectTo returning 302 in Angular SSR, with real outputs.

Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10, URL (Uniform Resource Locator) Rewriting Middleware), EF (Entity Framework) Core 10.0.14 and Angular 22.2; Google's interpretation of redirects is taken from Google Search Central as of November 2026.

Guess First

Bookstore changed its book URLs twice: first it moved from /old-books/{slug} to /books/{slug}, then Dune's slug was shortened from dune-1965 to dune. The team wrote two redirect rules, and both return 301. Also, the UseHttpsRedirection() line from the ASP.NET Core template is still there, as is, for the move from HTTP (HyperText Transfer Protocol) to HTTPS (HTTP Secure).

Three questions. How many redirects does a user or Googlebot go through after clicking an /old-books/dune-1965 link in an old blog post? Which status code does UseHttpsRedirection() return, and how does Google interpret it? If you redirect an old endpoint that a payment form POSTs to with a 301, what happens to the form data?

In the previous post we saw how status codes affect crawling and indexing, and left 3xx for this post. Redirects are the implementation of the sentence "if the slug changes, you owe a redirect" from the first post of the series, and of the "strongest canonical signal" row from the fifth.

301, 302, 307, 308: Two Questions, Four Codes

The four codes encode the answers to two questions: is the move permanent, and must the client keep the method (POST, PUT)?

CodePermanentMethod keptFor Google
301 Moved PermanentlyYesNo (clients turn POST into GET in practice)Strong canonical signal
302 FoundNoNoWeak signal; the source URL may stay indexed
307 Temporary RedirectNoYesEquivalent to 302
308 Permanent RedirectYesYesEquivalent to 301

Google's documentation draws this line clearly: 301 and 308 are a strong signal that the redirect target should be canonical; 302, 303 and 307 are a weak signal. With a temporary redirect, Google follows the target, but the indexing pipeline doesn't count it as a canonical signal; if other signals exist, the source URL may stay in the index. Google says to use permanent redirects when you're sure the redirect won't be reverted.

The method question is easy to see with real requests. Four endpoints redirect to the same target in four different ways:

// four redirect flavors to the same target
app.MapPost("/r/301", () => Results.Redirect("/echo", permanent: true));
app.MapPost("/r/302", () => Results.Redirect("/echo"));
app.MapPost("/r/307", () => Results.Redirect("/echo", preserveMethod: true));
app.MapPost("/r/308", () => Results.Redirect("/echo", permanent: true, preserveMethod: true));
app.MapMethods("/echo", ["GET", "POST"], (HttpRequest req) => $"{req.Method} /echo");
$ for c in 301 302 307 308; do printf "POST /r/$c -> "; curl -s -L -d "x=1" -H "X-Forwarded-Proto: https" http://localhost:20108/r/$c; echo; done
POST /r/301 -> GET /echo
POST /r/302 -> GET /echo
POST /r/307 -> POST /echo
POST /r/308 -> POST /echo

Mimicking the historical behavior of browsers, curl turns POST into GET and drops the body on 301 and 302; on 307 and 308, the method and body are kept. The answer to the third opening question: after a 301, the payment form arrives as a GET and the form data is lost. When moving API (Application Programming Interface) and form endpoints, use 308; when moving pages, use 301 or 308; to Google they're the same.

Other Redirect Types: Meta Refresh and JavaScript

If you can't redirect on the server, Google's table has other options. An instant (content="0") <meta http-equiv="refresh"> is interpreted by Google as a permanent redirect, and a delayed meta refresh as a temporary one. Changing location with JavaScript also counts as a permanent redirect, but Google sets a clear condition: use a JavaScript redirect only if a server-side or meta refresh redirect isn't possible, because if rendering fails, Google may never see the redirect. Remember from earlier posts: rendering happens in a separate queue after crawling, and non-200 responses may never be rendered at all.

There's also the "crypto redirect": a link on the page saying "we've moved, here's the new address". Google doesn't find this reliable as a permanent move signal; it's a last resort when nothing else is possible.

ASP.NET Core URL Rewriting Middleware

Bookstore's infrastructure redirects, namely HTTPS, www and the old path prefix, sit in one place with the URL Rewriting Middleware:

// behind a reverse proxy: X-Forwarded-Proto tells us the original scheme
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto });

using StreamReader iisRules = File.OpenText("IISUrlRewrite.xml");
RewriteOptions rewrite = new RewriteOptions()
    .AddRedirectToHttpsPermanent()
    .AddRedirectToWwwPermanent("bookstore.example")
    .AddIISUrlRewrite(iisRules);
rewrite.AddRedirect("^old-books/(.*)", "books/$1", StatusCodes.Status301MovedPermanently);
app.UseRewriter(rewrite);
$ curl -sI http://localhost:20108/books/dune
HTTP/1.1 301 Moved Permanently
Location: https://localhost/books/dune

$ curl -sI -H "Host: bookstore.example" -H "X-Forwarded-Proto: https" http://localhost:20108/books/dune
HTTP/1.1 308 Permanent Redirect
Location: https://www.bookstore.example/books/dune

$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/old-books/dune
HTTP/1.1 301 Moved Permanently
Location: /books/dune

A notable detail: AddRedirectToHttpsPermanent returned 301, while AddRedirectToWwwPermanent returned 308; both carry "Permanent" in their names but don't use the same code. To Google there's no difference, but don't be surprised if you expect 301 in logs or tests. I gave AddRedirectToWwwPermanent a domain list; without it, it redirects every host, localhost included, to the www. prefix. UseForwardedHeaders matters here: if the reverse proxy in front terminates TLS (Transport Layer Security), the application sees every request as HTTP and the HTTPS rule loops forever. Setting up forwarded headers safely is covered in the Web Servers and Hosting series.

The answer to the second opening question is here too. The default for the template's UseHttpsRedirection() is 307:

$ curl -sI http://localhost:20109/
HTTP/1.1 307 Temporary Redirect
Location: https://localhost/

A sensible choice for development (a permanent redirect gets cached by the browser and is a nuisance on localhost), but in production the move from HTTP to HTTPS is a permanent decision. Microsoft Learn's HTTPS documentation also shows setting RedirectStatusCode to a permanent code for production. Since Google already prefers HTTPS, 307 isn't a disaster, but for consistent canonical signals, 301 or 308 is the right one.

A team using the IIS (Internet Information Services) URL Rewrite module doesn't lose its existing rules either: AddIISUrlRewrite reads IIS's <rewrite> XML (Extensible Markup Language) format. The rule below ran on Kestrel, without IIS:

<rewrite>
  <rules>
    <rule name="Old catalog" stopProcessing="true">
      <match url="^catalog/(.*)$" />
      <action type="Redirect" url="books/{R:1}" redirectType="Permanent" />
    </rule>
  </rules>
</rewrite>
$ curl -sI -H "X-Forwarded-Proto: https" http://localhost:20108/catalog/dune
HTTP/1.1 301 Moved Permanently
Location: /books/dune

Which one to choose? If the application is behind IIS, a rule defined in IIS answers the request without it ever reaching the application; good for performance, but the rules live apart from the application code, in a configuration that isn't versioned with the deploy. A rule in middleware is tested with the code and works the same on every host. Keeping infrastructure rules (HTTPS, host) in front and content rules (slug history) in the application is a good split.

301s from a Slug History in the Database

Slug changes can't be expressed with a regex; every book has its own history. Bookstore keeps it in a table. The critical design decision: an old slug points not to another slug but to the book itself.

public sealed class Book
{
    public int Id { get; set; }
    public required string Slug { get; set; }
    public required string Title { get; set; }
}

// every slug a book has ever had; points to the book, not to another slug
public sealed class SlugHistory
{
    public required string OldSlug { get; set; }
    public int BookId { get; set; }
}
app.MapGet("/books/{slug}", async (string slug, BookstoreDb db) =>
{
    Book? book = await db.Books.SingleOrDefaultAsync(b => b.Slug == slug);
    if (book is not null)
        return Results.Content($"<h1>{book.Title}</h1>", "text/html");

    string? current = await CurrentSlugAsync(db, slug);
    return current is null ? Results.NotFound() : Results.Redirect($"/books/{current}", permanent: true);
});

// old slug? jump straight to the current slug: one hop, never a chain
static Task<string?> CurrentSlugAsync(BookstoreDb db, string oldSlug) =>
    db.SlugHistory
        .Where(h => h.OldSlug == oldSlug)
        .Join(db.Books, h => h.BookId, b => b.Id, (h, b) => b.Slug)
        .SingleOrDefaultAsync();

Dune's slug changed twice: dune-1965 → frank-herbert-dune → dune. If the history table stored "old slug → new slug", the first slug would go to the second, the second to the third, and every change would add a hop to the chain. Because it points to the book, both land on the current address in a single hop:

$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/books/dune-1965
HTTP/1.1 301 Moved Permanently
Location: /books/dune
$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/books/frank-herbert-dune
HTTP/1.1 301 Moved Permanently
Location: /books/dune

Redirect Chains

Back to the first opening question. The regex rule and the slug history are each correct on their own, but together they form a chain:

$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/old-books/dune-1965
HTTP/1.1 301 Moved Permanently
Location: /books/dune-1965
$ curl -s -L -H "X-Forwarded-Proto: https" -o /dev/null -w "hops=%{num_redirects} final=%{url_effective} %{http_code}\n" http://localhost:20108/old-books/dune-1965
hops=2 final=http://localhost:20108/books/dune 200

Once the HTTPS and www rules in front kick in as well, http://bookstore.example/old-books/dune-1965 goes up to four hops. Googlebot follows up to 10 hops by default, and permanent redirects don't lose PageRank; so the chain isn't a disaster. But every hop is a round trip and latency for the user, and both Google's redirect guide and its crawl budget documentation recommend avoiding long chains. The fix is to bind the old prefix to the same resolver; I removed the regex rule and turned /old-books/{slug} into an endpoint that also knows the slug history:

// legacy prefix and old slug resolved together: a single hop to the final URL
app.MapGet("/old-books/{slug}", async (string slug, BookstoreDb db) =>
{
    string? current = await db.Books.Where(b => b.Slug == slug).Select(b => b.Slug).SingleOrDefaultAsync()
        ?? await CurrentSlugAsync(db, slug);
    return current is null ? Results.NotFound() : Results.Redirect($"/books/{current}", permanent: true);
});
$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/old-books/dune-1965
HTTP/1.1 301 Moved Permanently
Location: /books/dune
$ curl -s -L -H "X-Forwarded-Proto: https" -o /dev/null -w "hops=%{num_redirects} final=%{url_effective} %{http_code}\n" http://localhost:20108/old-books/dune-1965
hops=1 final=http://localhost:20108/books/dune 200

The general principle: every redirect should go straight to the final target. As long as infrastructure rules (HTTPS, host) and content rules (slug) are independent of each other, a single hop isn't always possible; but making sure the content rules don't chain among themselves is up to you.

Redirects in Angular SSR: The 302 Default

A redirectTo in the Angular router becomes a real HTTP redirect under SSR (Server-Side Rendering). The first time I ran the { path: 'old-books/:slug', redirectTo: 'books/:slug' } route in Bookstore's Angular application, the code returned was:

$ curl -sI http://localhost:20110/old-books/dune | head -3
HTTP/1.1 302 Found
X-Powered-By: Express
location: /books/dune

302, meaning a temporary, weak canonical signal. For a permanent URL move you need to set the status explicitly on the server route:

{ path: 'old-books/:slug', renderMode: RenderMode.Server, status: 301 },
$ curl -sI http://localhost:20110/old-books/dune | head -3
HTTP/1.1 301 Moved Permanently
X-Powered-By: Express
location: /books/dune

angular.dev says that during prerendering redirectTo becomes a "soft redirect" with <meta http-equiv="refresh">. A detail came up while testing: even though I marked the { path: 'shop', redirectTo: '' } route with RenderMode.Prerender, the build produced no file for it, and the server answered the request with 302:

$ curl -sI http://localhost:20110/shop | head -3
HTTP/1.1 302 Found
X-Powered-By: Express
location: /

The prerender code of @angular/build 22.2.6 explains this: if the build's outputMode isn't static, redirect routes are skipped during prerendering and left to the server's HTTP redirect. The meta refresh page is produced only for serverless (outputMode: "static") builds; and there, since it's an instant meta refresh, Google treats it as a permanent redirect. That's why, in projects with a server, setting the redirect's status code explicitly on the server route matters.

Redirects on mertozen.com

The largest set of redirects in this site's source code came with release v1.12.0 on 7 November 2026: language selection moved from the ?lang=en query parameter to the /en/ subfolder, and the old ?lang= addresses are redirected to the new ones with 301; /en also gets a 301 to a single canonical address, /en/. The reasoning and plan behind this move are the topic of the tenth and twelfth posts of the series; here let's look at two redirect details.

First, the legacy redirects keep the rest of the query string: ?lang=en&page=2 is redirected with lang dropped and page=2 carried over. That's the right behavior; a redirect that dropped parameters would silently break pagination or filter links.

Second, the site's Program.cs has UseHttpsRedirection() with its default settings; as we saw above, that means 307. Since the site runs on IIS and Plesk, there may be a separate HTTP-to-HTTPS 301 rule in front; in that case the request never reaches the application. I can't see that from the source code. If there's no such rule in front, setting RedirectStatusCode to 308 with AddHttpsRedirection is a one-line change. The decision is the site owner's.

Production Notes

Keep redirects for at least a year, preferably indefinitely. Google's site move guide recommends at least a year; external links and bookmarks live for years. A slug history table with a few thousand rows can stay forever.

Write redirect tests: a table test asserting the expected target and hop count for every old URL. Chains appear when a newly added rule interacts with another one; they aren't noticed by hand. In the last post of the series we'll build a verification script that crawls all old URLs after a migration.

Check that the redirect target returns 200 and is its own canonical. If the redirect target gives its canonical to somewhere else, or carries noindex, the signals conflict.

In Search Console, the "Page with redirect" row of the "Pages" report lists the redirected URLs Google knows about. If you see URLs from your sitemap in that list, your sitemap is out of date: the sitemap should contain only redirect targets.

Common Pitfalls

Using 302 for a permanent move. Angular SSR's default and the Redirect() call of many frameworks is 302; a weak signal for Google.

Moving a form or API endpoint with 301. POST turns into GET and the body is lost; use 308.

Chaining old slugs as "old → new". Every change lengthens the chain; tie the history to the entity.

Redirecting every deleted page to the home page. Mass redirects to an irrelevant target are among the things Google's guide says to avoid; if there's no relevant target, return 404 or 410.

Automatic redirects based on a language or country guess. Googlebot usually crawls from the USA without sending an Accept-Language header; redirecting by IP (Internet Protocol) or browser language can keep Googlebot from ever seeing some language versions. Google recommends a language selector instead; details in the tenth post of the series.

A Small Experiment

★☆☆ Request an http://, non-www, uppercase URL on your own site with curl -s -L -o /dev/null -w "%{num_redirects} %{url_effective}\n". How many hops are there, and with which codes?

★★☆ Write a custom IRule for RewriteOptions: combine HTTPS, www and lowercase normalization into a single rule that computes the final URL and returns a single 301. Compare the hop count with the previous version.

★★★ Design an admin flow for the slug history table: when an editor changes a slug, the old slug is automatically written to SlugHistory; if the new slug collides with another book's old slug, the save is rejected; and when a book is deleted, its historical slugs return 410. Verify the three scenarios with integration tests.

Up to here, everything was about the response the server sends. But in an application that produces its content in the browser with JavaScript, Googlebot sees an almost empty HTML (HyperText Markup Language) document on the first response. In the next post we'll look at the difference between CSR (Client-Side Rendering), SSR and SSG (Static Site Generation), Googlebot's render queue, and measure how the same Angular page looks to a crawler in two render modes.