301 vs 302 Redirects (and 307, 308): Which One Should You Use?

8 min readhttp, seo

Every HTTP redirect tells the client two things: where to go, and how to treat the move. The Location header handles the first part. The status code handles the second — and that is where people get sloppy. Four codes cover almost every redirect you will ever ship: 301, 302, 307, and 308. They differ along exactly two axes: whether the move is permanent, and whether the client is allowed to change the request method when it follows the redirect.

Choosing wrong has consequences that range from mildly annoying to genuinely hard to undo. A 301 you did not mean can be cached by browsers more or less indefinitely, so visitors keep landing on the wrong page long after you fix the server. A 301 in front of an API endpoint can silently convert a POST into a GET and drop the request body. And a redirect chosen for imagined SEO reasons often solves a problem Google stopped having years ago.

By the end of this post you will know what each code actually means per the spec, how browsers cache them, how Google treats them, and — via a short decision table — which one to reach for in any given situation, plus the exact incantations in Next.js, nginx, and raw HTTP.

What each redirect status code means

The current definitions live in RFC 9110, the HTTP semantics spec, with a friendlier walkthrough in MDN’s redirections guide. Here is the whole landscape in one table:

CodeNamePermanent?Method preserved?Cached by default?
301Moved PermanentlyYesNo — may become GETYes
302FoundNoNo — may become GETNo
307Temporary RedirectNoYesNo
308Permanent RedirectYesYesYes

Read the table column by column and the pattern is obvious: 301 and 308 are the permanent pair, 302 and 307 are the temporary pair. Within each pair, the newer code (307, 308) is the strict one that forbids changing the request method. That split exists for a historical reason worth understanding.

The method problem: why 307 and 308 exist

HTTP/1.0 defined 301 and 302 with the intention that clients would repeat the same request against the new URL. Browsers ignored that. When they received a 301 or 302 in response to a POST, they overwhelmingly reissued the request as a GET — no body, different semantics. Rather than fight every browser in existence, the spec capitulated: RFC 9110 now says a client following a 301 or 302 may change POST to GET, and in practice you should assume it will.

HTTP/1.1 added 307, and RFC 7538 later added 308, precisely to close that loophole. Both codes forbid changing the method: a POST stays a POST, body and all. For plain page navigation — which is always GET — the distinction is invisible, and 301 versus 308 is a coin flip. For anything that accepts writes, it is the whole ballgame. If you move an API endpoint and redirect with a 301, clients that dutifully follow it will arrive at the new URL as a bodyless GET, and you will spend an afternoon staring at confusing logs.

One more code deserves a mention: 303 See Other is the redirect that intentionally changes the method to GET. It exists for the post/redirect/get pattern — handle a form submission, then bounce the browser to a results page so a refresh does not resubmit the form. If you want the method changed, say so with a 303; do not rely on the sloppy legacy behavior of 302.

Browser caching: the 301 trap

This is the difference that bites hardest in practice. Under RFC 9110, 301 and 308 responses are heuristically cacheable: a browser may cache them without any explicit caching headers, and browsers do — often for a very long time. Once a user’s browser has cached your permanent redirect, subsequent visits to the old URL jump straight to the new one without contacting your server at all.

That is exactly what you want when the move is real. It is a slow-motion disaster when it is not. If you ship a wrong 301 — pointing at the wrong page, or set up as a quick hack during a migration — fixing the server does not fix your visitors. Their browsers keep replaying the cached redirect, and there is no way to reach into their caches and clear it. Your realistic options are to serve a counter-redirect from the destination URL back to the right place, or to wait it out.

Three habits keep you out of that hole:

Temporary redirects have the opposite property: 302 and 307 are only cached if you attach explicit freshness headers. Every request comes back to your server, which means every request stays under your control — and, usefully, gets observed. If your destination URLs carry tracking parameters like the ones covered in the UTM parameters guide, a click that never reaches your server is a click your analytics never see.

What redirects mean for SEO

The folk wisdom — always 301, a 302 leaks PageRank — is outdated. Google’s own documentation, Redirects and Google Search, describes both permanent and temporary redirects as canonicalization signals, and Google has said publicly that PageRank passes through 30x redirects without loss. The difference today is about which URL gets indexed, not how much authority survives the hop.

The practical takeaway: pick the code that describes reality, and the SEO takes care of itself. Using a 301 for a temporary move to chase a ranking benefit buys you nothing from Google and costs you the caching flexibility described above. I dig into the adjacent question — what happens to link equity when clicks route through a shortener — in Do URL Shorteners Hurt SEO?.

A practical decision table

SituationUse
Page moved for good; traffic is browser GETs301 (or 308)
Endpoint moved for good; clients send POST/PUT308
Temporary move: maintenance, experiment, geo routing302
Temporarily rerouting requests that carry a body307
After handling a form POST, show a normal page303

Two rules compress the whole table. First: permanent only when you are certain, temporary whenever you are not — you can always upgrade a temporary redirect later, but you cannot recall a cached permanent one. Second: if any request through the redirect might not be a GET, use the method-preserving pair (307/308) and remove the ambiguity entirely.

Setting redirects in common stacks

Next.js

In the App Router, redirect() and permanentRedirect() from next/navigation are the in-code options (see the Next.js redirect docs). Note the status codes they emit — they use the modern method-preserving pair, not 301/302:

import { redirect, permanentRedirect } from "next/navigation";

// In a Server Component or Route Handler:
redirect("/temporary-home");     // responds with 307
permanentRedirect("/new-home");  // responds with 308

// Inside a Server Action, both respond with 303 so the
// browser follows the redirect with a GET after the POST.

For static route moves, declare them in config instead so they run at the edge of the framework:

// next.config.ts
const nextConfig = {
  async redirects() {
    return [
      {
        source: "/old-blog/:slug",
        destination: "/blog/:slug",
        permanent: true, // sends 308; false sends 307
      },
    ];
  },
};

export default nextConfig;

If you need a specific legacy code — say a literal 301 for a picky client — do it in middleware with NextResponse.redirect(url, { status: 301 }). In Next.js 16 that file is now proxy.ts; the rename is covered in From middleware.ts to proxy.ts.

nginx

# Permanent move (cached by browsers)
location = /old-page {
  return 301 https://example.com/new-page;
}

# Temporary (every request hits the server)
location = /promo {
  return 302 https://example.com/summer-sale;
}

# return also accepts 303, 307, and 308 on any recent nginx
location = /api/v1/items {
  return 308 https://example.com/api/v2/items;
}

Raw HTTP, any stack

Ultimately a redirect is just a status line, a Location header, and optionally a Cache-Control header to bound how long it sticks:

HTTP/1.1 308 Permanent Redirect
Location: https://example.com/api/v2/items
Cache-Control: max-age=3600

Why this site’s shortlinks redirect temporarily

This domain doubles as my personal link shortener, and every shortlink resolves with a temporary redirect — Next.js’s redirect(), so a 307 — rather than a 301. That is a deliberate trade. The redirect handler records each click in Supabase before forwarding, and a permanent redirect would defeat exactly that: after the first visit, the browser would replay the cached redirect and repeat clicks would never reach the server, quietly undercounting everything. A temporary code keeps every click observable and lets me repoint a slug at a new destination whenever I want.

The cost is one extra round trip per click and a weaker canonical signal to Google — both irrelevant for a shortener, since the destination page is the canonical URL anyway and the hop takes tens of milliseconds. If you want to build the same thing, the full walkthrough is in Build a URL Shortener with Next.js and Supabase.

Bottom line