Type something to search...
How does file-based routing work in Astro?

How does file-based routing work in Astro?

File-based routing in Astro means every supported file inside src/pages/ becomes a URL on your site, and the file's path decides what that URL is. src/pages/about.astro is served at /about, src/pages/blog/index.astro at /blog, and src/pages/blog/hello-world.md at /blog/hello-world. There is no router configuration file to maintain: add a file and the route exists, delete it and the route is gone. Square brackets in a file name, such as [slug].astro, turn a single file into a dynamic route that can produce many pages.

This convention is one of the reasons Astro projects stay easy to navigate. You can look at the folder tree and know the shape of the site. In this article you'll learn how file paths map to URLs, which file types can live in src/pages/, how to keep helper files out of the router, how dynamic routes fit in, what the build output looks like with each build.format option, how trailing slashes behave, and what the new src/fetch.ts entrypoint in Astro 7 changes (and doesn't change) about routing.

The Basic Mapping

Astro reads the src/pages/ directory and creates one route per file. The rules are simple:

  • The path becomes the URL: folders become URL segments, and the file name becomes the last segment.
  • index files map to their folder: index.astro inside a folder answers at the folder's URL.
  • The extension is dropped: .astro, .md, .mdx and .html extensions don't appear in the URL.

Here is a small project and the URLs it produces:

src/pages/index.astro             -> /
src/pages/about.astro             -> /about
src/pages/about/index.astro       -> /about   (same URL, pick one)
src/pages/blog/index.astro        -> /blog
src/pages/blog/hello-world.md     -> /blog/hello-world
src/pages/docs/guides/setup.astro -> /docs/guides/setup

Notice that about.astro and about/index.astro both claim /about. They're interchangeable. Use the folder version when you expect sibling pages under /about/ later, and the single file when you don't.

A Minimal Page

A page is just an Astro component that renders a full HTML document. Most real pages wrap their content in a layout so they don't repeat the <html> and <head> markup:

---
// src/pages/index.astro
import Base from "../layouts/Base.astro";
---
<Base title="Home">
  <h1>Welcome</h1>
  <p>Current path: {Astro.url.pathname}</p>
</Base>

The layout is an ordinary component outside src/pages/, so it never becomes a route itself:

---
// src/layouts/Base.astro
interface Props {
  title: string;
}
const { title } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{title}</title>
  </head>
  <body>
    <nav>
      <a href="/">Home</a>
      <a href="/about/">About</a>
      <a href="/blog/">Blog</a>
    </nav>
    <main>
      <slot />
    </main>
  </body>
</html>

If layouts are new to you, the guide on creating reusable layouts in Astro covers them in detail.

One Astro 7 detail matters here: the Rust compiler is now the only compiler, and it requires every non-void element to be closed. An unclosed <p> or <li> that older versions silently accepted will now fail the build, so keep your page markup tidy.

Linking Between Pages

Astro has no special <Link> component. You navigate with plain <a> elements and root-relative URLs:

<a href="/blog/hello-world/">Read the first post</a>

Use a path that starts with /, not a relative file path like ../blog/hello-world.md. The link points at the URL the page is served from, not at the source file. If you configure a base in your Astro config (for example when deploying to a subfolder), include that base in your links.

Supported File Types in src/pages

Not every file in src/pages/ is treated the same way. Astro recognises these types:

File typeWhat it becomesNotes
.astroAn HTML pageFull Astro component features
.mdAn HTML pageUse the layout frontmatter property to wrap it
.mdxAn HTML pageRequires the @astrojs/mdx integration
.htmlAn HTML pageCopied in as a page, with limited Astro features
.js / .tsAn endpointServes any data, such as JSON, XML or images

Markdown pages are handy for one-off content like a privacy policy. The layout frontmatter property points to a layout component that wraps the rendered Markdown:

---
layout: ../../layouts/MarkdownPage.astro
title: Hello world
---

# Hello world

This page was written in **Markdown**.

The layout receives the file's frontmatter as a frontmatter prop, not as individual props, so read the title from there:

---
// src/layouts/MarkdownPage.astro
import Base from "./Base.astro";
const { frontmatter } = Astro.props;
---
<Base title={frontmatter.title}>
  <article>
    <slot />
  </article>
</Base>

For a whole blog, though, a content collection is usually a better fit than a folder of Markdown pages, because you get schema validation and query helpers. See what content collections are in Astro for that approach.

Endpoints Live in the Same Tree

A .js or .ts file in src/pages/ is an endpoint rather than a page. Its name should include the extension of the file you want to produce, because Astro strips only the .ts part:

// src/pages/data.json.ts
import type { APIRoute } from "astro";

export const GET: APIRoute = () => {
  return Response.json({ site: "example", pages: 4 });
};

This builds a file at /data.json. Since Astro 6, endpoints whose URL ends in a file extension can only be reached without a trailing slash, so link to /data.json, never /data.json/. Endpoints get their own article: how to build API endpoints in Astro.

Keeping Files Out of the Router

It's often convenient to keep a component or utility next to the page that uses it. Prefix the file or folder name with an underscore and Astro ignores it when building routes:

src/pages/
  _drafts/
    idea.astro        (ignored)
  _helpers.ts         (ignored)
  index.astro         -> /
  projects/
    _ProjectCard.astro (ignored)
    project-one.md    -> /projects/project-one

Files with a leading _ aren't recognised by the router and don't end up in dist/. In a test build of the tree above, requesting /_drafts/idea in the dev server returned a 404, exactly as if the file didn't exist.

This is also a simple way to temporarily disable a page without deleting it: rename pricing.astro to _pricing.astro.

Static Routes and Dynamic Routes

Everything so far has been a static route: one file, one URL. Astro also supports dynamic routes, where part of the file name is a parameter in square brackets.

PatternExample fileMatches
Staticblog/index.astro/blog only
Named parameterblog/[slug].astro/blog/anything (one segment)
Several parameters[lang]/[version]/info.astro/en/v1/info
Rest parameterdocs/[...path].astro/docs, /docs/a, /docs/a/b/c

In the default static output, a dynamic page must export getStaticPaths() so Astro knows which values to build at build time:

---
// src/pages/dogs/[dog].astro
export function getStaticPaths() {
  return [
    { params: { dog: "clifford" } },
    { params: { dog: "rover" } },
  ];
}

const { dog } = Astro.params;
---
<p>Good dog, {dog}!</p>

That one file generates /dogs/clifford and /dogs/rover. Pages rendered on demand skip getStaticPaths() and match any value at request time instead. Both topics have dedicated posts: dynamic routes with getStaticPaths and rest parameters for catch-all routes.

When a static route and a dynamic route could both match the same URL, Astro has a clear set of priority rules. posts/create.astro always beats posts/[id].astro for /posts/create, for example. The details are in route priority in Astro.

What the Build Output Looks Like

In static mode, each route becomes a file in dist/. The build.format option controls the shape of those files. I built the example project with each setting:

# build.format: 'directory' (default)
dist/404.html
dist/about/index.html
dist/blog/hello-world/index.html
dist/blog/index.html
dist/data.json
dist/index.html
dist/legacy/index.html
# build.format: 'file'
dist/404.html
dist/about.html
dist/blog.html
dist/blog/hello-world.html
dist/index.html
dist/legacy.html
# build.format: 'preserve'
dist/404.html
dist/about.html
dist/blog/hello-world.html
dist/blog/index.html
dist/index.html
dist/legacy.html
  • directory wraps every page in its own folder with an index.html, which gives clean URLs on almost any static host.
  • file writes about.html style files. Some hosts serve these at /about automatically; others need configuration.
  • preserve mirrors your source tree: about.astro becomes about.html, while blog/index.astro stays blog/index.html.

The 404.astro page is always written as 404.html, which is the file most hosts look for. There's more on that in creating a custom 404 page in Astro.

Setting the Format

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  build: {
    format: "file",
  },
});

The format also affects Astro.url during the build. With directory, Astro.url.pathname includes a trailing slash (/about/). With file, it ends in .html. Keep that in mind if you build relative URLs with new URL("./something", Astro.url).

Trailing Slashes

Should /about and /about/ both work? The trailingSlash option decides how the dev server and on-demand pages match them:

  • 'ignore' (default): both forms match the same route.
  • 'always': only /about/ matches. In production, on-demand pages redirect the other form.
  • 'never': only /about matches, with the same redirect behaviour in production.
// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  trailingSlash: "always",
  build: {
    format: "directory",
  },
});

For prerendered pages, trailing slash handling is up to your hosting platform, because the host serves the files directly. Pairing trailingSlash: "always" with the directory format, or "never" with file, keeps development and production consistent.

Advanced Routing and src/fetch.ts in Astro 7

Astro 7 made advanced routing stable and enabled it by default. It doesn't replace file-based routing. Your src/pages/ tree still defines every route. What it adds is a hook into the request pipeline that sits in front of those routes.

By default, Astro handles each request with a built-in pipeline: trailing-slash normalisation, redirects, sessions, actions, your middleware, page rendering, i18n and caching. If you create src/fetch.ts, Astro uses it as the entrypoint instead. The simplest useful version runs the whole default pipeline with astro() and adds something around it:

// src/fetch.ts
import { FetchState, astro } from "astro/fetch";

export default {
  async fetch(request: Request): Promise<Response> {
    const state = new FetchState(request);
    const response = await astro(state);
    response.headers.set("X-Matched-Route", state.routeData?.route ?? "none");
    return response;
  },
};

FetchState resolves which file-based route matches the request, and state.routeData exposes it. Running this in the dev server shows the router at work:

curl -s -o /dev/null -D - http://localhost:4321/about/ | grep -i x-matched
curl -s -o /dev/null -D - http://localhost:4321/nope | grep -i x-matched
x-matched-route: /about
x-matched-route: /404

/about/ matched src/pages/about.astro, and an unknown URL fell through to the 404 route.

Three things are worth knowing before you reach for this file:

  1. src/fetch.ts is a reserved name: if your project already has a src/fetch.ts for something else, such as an API client, Astro will try to load it as the entrypoint. Rename your file, or set fetchFile in the config to another name or to null to disable the feature.
  2. It only runs for requests that reach the server: when I built the same project with the Node adapter, the custom header appeared on an on-demand page and on the 404 response, but not on prerendered pages, which the server sent as static files. A fully static site has no request pipeline in production at all.
  3. Don't authorise by pathname string: the docs warn that the incoming pathname isn't guaranteed to match the route Astro resolves internally, so a check like pathname.startsWith("/dashboard") can be bypassed. Use middleware or a router that owns route matching for access control.

If you have an existing file you want to keep, the config change is one line:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  fetchFile: null,
});

For most sites you won't need src/fetch.ts at all. File-based routing plus middleware covers the common cases, and the entrypoint is there for when you want full control over the order of things.

Common Mistakes

A few problems come up again and again with file-based routing:

  • Pages outside src/pages: a component in src/components/ is never a route, no matter what it renders.
  • Linking to source files: write /blog/hello-world/, not ./hello-world.md.
  • Two files for one URL: about.astro and about/index.astro together create a conflict. Keep one.
  • Forgetting the underscore: a utils.ts file in src/pages/ is picked up as an endpoint route. Name it _utils.ts or move it to src/lib/.
  • Expecting dynamic routes to build without getStaticPaths(): in static mode, a [param] page without it fails the build with a GetStaticPathsRequired error, which also suggests adding export const prerender = false if you meant the page to be rendered on demand.

FAQ: File-Based Routing in Astro

No. Astro builds its routes from the files in src/pages. Adding a supported file creates a route and removing it deletes the route. Configuration only comes into play for things like redirects, i18n and trailing slash behaviour.

Pages always live in a pages folder inside your source directory. You can move the whole source directory with the srcDir option, but the pages folder name itself stays the same.

Prefix the file or folder name with an underscore, such as _Card.astro or _drafts/. Astro ignores those files when it builds routes, so they never appear in dist.

Both produce the /about URL. The folder version is convenient when you plan to add more pages under /about/, but you should only keep one of the two to avoid a conflict.

No. Your routes still come from src/pages. The fetch entrypoint lets you wrap or reorder the request pipeline that runs in front of those routes, and it only applies to requests the server actually handles.

The most common causes are trailing slash differences between dev and your host, and the build.format setting. Match trailingSlash to your format, and check how your host serves files like about.html versus about/index.html.


Conclusion

Astro's file-based routing turns your src/pages/ folder into your site map. Folders become URL segments, index files answer at their folder's URL, extensions disappear, and underscored files stay private. Markdown, MDX and HTML files become pages, while .js and .ts files become endpoints, all in the same tree.

Once the basics are clear, the rest builds on top: square brackets for dynamic routes, build.format and trailingSlash to shape your output, and, in Astro 7, an optional src/fetch.ts for taking control of the request pipeline. Start with a clean folder structure and most routing questions answer themselves.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper