Knowledge baseAugust 21, 2026·updated August 21, 2026

Astro: a static site that can handle thousands of pages

Astro assembles each page into ready-made HTML at the build stage and does not load JavaScript into the browser by default. Analysis of mechanics: routes from files, collections with a schema, islands, assembly in dist/ - and where this approach stops working.

What does this solve?

A website with a thousand or more pages depends on three things: first render speed, completeness of indexing and the cost of a live backend. Astro assembles each page into ready-made HTML at the build stage and by default does not send a single line of JavaScript to the browser. The folder remains in production dist/ and any hosting that can serve files. A product catalog, a knowledge base, documentation, a blog with an archive for five years - this is the typical load for which such a generator is made.

The second pain that it relieves is the discrepancy in content. When articles are written by five people, the fields in frontmatter spread out: someone date, someone has published, the third has a date in the format “12.03”. Astro allows you to describe the content scheme and drop the assembly at the first discrepancy, rather than rolling out a page with an empty date.

How it works

The routes are in the file system

File path to src/pages/ is the URL. src/pages/index.astro gives /, src/pages/docs/install.astro gives /docs/install. The square brackets in the name give the parameter: src/pages/blog/[slug].astro.

The dynamic route must explain to the collector which specific pages to generate. Export is responsible for this getStaticPaths() - it is executed once during assembly and returns a list of parameters:

---
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<h1>{post.data.title}</h1>
<article><Content /></article>

Upper block between --- is executed on the server during build. Everything that happens in it - requests to files, to the API, to the database - does not go to the browser: only the result goes there.

In projects started with earlier versions, there is an old version of the same code: post.slug instead post.id i await post.render() instead await render(post). Both forms do the same thing; it only makes sense to change them when upgrading.

The content is described by a diagram

A collection is a set of files of the same type plus rules for parsing them. The config is in src/content.config.ts (in old projects - src/content/config.ts):

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).max(5),
  }),
});

export const collections = { blog };

Next getCollection('blog') provides typed records, and the editor suggests fields. File with typo in date will not build at all - an error will appear in the build console indicating the path. With nine hundred articles, this is the only way to keep things in order without manual proofreading.

The boot loader is not required to read the disk. glob() takes markdown from the repository, file() - one JSON or CSV with a list of records; its own loader can go to the CMS or database and return the same records with a schema. For the catalog, this means: products are pulled from an external source once during assembly, and the output is still regular HTML files.

Islands

A React, Vue or Svelte component inserted into .astro-page without directives will be rendered in HTML during assembly and will not send any JavaScript to the browser. Interactivity is enabled explicitly:

<Search client:load />        <!-- гидратировать сразу -->
<Comments client:visible />   <!-- когда доскроллят -->
<Chart client:idle />         <!-- когда браузер освободится -->
<Map client:only="react" />   <!-- не рендерить на сервере вовсе -->

Each such component is assembled into a separate bundle with its own dependencies. Page with one client:visible below does not pull the framework until the user scrolls.

Assembly

astro build passes all routes, performs getStaticPaths(), renders pages and adds the result to dist/ —one at a time index.html for each URL plus common assets with a hash in the name. astro preview elevates a local server on top of an existing one dist/to see exactly what will go to hosting.

The assembly traverses the routes sequentially, so the time increases approximately in proportion to the number of pages. This is the main expense that will have to be planned.

Adapters and Spot SSR

The project is static by default. If part of the site needs on-demand rendering, an adapter is installed (@astrojs/node, @astrojs/cloudflare, @astrojs/vercel, @astrojs/netlify), after which the individual page is marked export const prerender = false starts executing on the server. The remaining pages remain files. This is how hybrids live: the catalog is static, the shopping cart and personal account are dynamic.

Images

astro:assets processes pictures during assembly: component <Image /> generates derivatives of the required sizes and formats, enters width/height and removes the layout jump. External images must be explicitly allowed in the config via image.domains or image.remotePatterns —otherwise Astro won't touch them. Processing thousands of images significantly lengthens the build, so large catalogs usually prepare derivatives in advance and put them in a CDN.

Useful scripts

  • Knowledge base or documentation: markdown in the repository, diagram on frontmatter, search through a pre-built index, edits come via pull request.
  • A catalog that changes once a day: nightly assembly pulls products from the API and places them in static cards.
  • Blog and media section with archive: old pages do not require a backend, database, or extensions.
  • Multilingual site: language becomes route segment (src/pages/[lang]/...), translations are in a collection with the same scheme.
  • Landing pages for campaigns: common components, different content, separate assembly for each launch.
  • Showcase on top of a heavy legacy backend: public pages are rendered static, and calls to the old system remain only in the islands.

Limitations

Build time increases with the number of pages. A typo in one article means a full run and a new deployment, and not an edit in the admin panel. On large volumes, minutes pass between “corrected” and “saw on production,” and this must be built into the editorial process.

Content that changes every minute does not fit here. You can’t display stock balances, prices, order statuses through assembly - either an island that goes to the API, or a page with prerender = false, that is, the backend, which again needs to be kept alive.

Search and filters across the entire database do not work out of the box. You need either a pre-built index (Pagefind and similar tools can build it from a ready-made one) dist/), or an external search service. Filtering a directory by five parameters on the client depends on the size of the index.

Personalization breaks the main benefit. Recycle bin, office, “have you looked” - all these are islands with requests to the API, and the more there are, the closer the result is to a regular SPA with an extra layer on top.

A strict scheme costs money when changing. We added a mandatory field - nine hundred files must be migrated, otherwise the build will fail. You have to write the frontmatter migration script yourself.

Default page transitions are a complete reboot, the state on the client is lost. ClientRouter gives soft transitions, but returns to the JavaScript bundle that was abandoned.

The ecosystem is smaller than that of large frameworks: some libraries rely on client rendering and are not built statically, and plugins sometimes lag behind the latest versions of Astro itself.

How to check the result

Launch astro build and read the output: how many pages were collected, how long it took, whether there are any warnings about the scheme. The number of pages should be as expected - a discrepancy usually means that getStaticPaths() did not return the entire list (a common reason is the filter by draft).

Open a specific result file and find the text in it:

grep -c "ключевая фраза" dist/blog/kakaya-to-statya/index.html

If the phrase is in the file, the search robot also sees it. If it is not there, but it appears on the screen, the content is drawn as an island and will not be included in the index.

Raise astro preview, open the page and look at the Network tab: the page has no request islands .js should not exist at all. Each extra bundle is searched by file name - it points to the component.

Drive away astro check —checks types and schemas without assembly, catches errors in templates faster.

Check dist/sitemap-index.xml and related files: number <url> should match the number of collected pages. Missing URLs are a sign of a route that was not included in getStaticPaths().

Separately check the hosting: a non-existent address should give a 404 code, not a 200 with the text “page not found”. Static hosting sites are configured for this manually, and the error persists for years.

One line rule: if a page is needed by a search, it must exist in dist/ as a separate HTML file with the full text inside, and everything else is transferred to the islands.

  • astro
  • static
  • ssg
  • frontend