Content Loaders

Purpose and Scope

Content loaders are the bridge between content stored somewhere and Astro’s content layer. A loader retrieves entries, gives each entry an identity, and makes the resulting data available through content collection APIs. In day-to-day Astro projects, the two most important local loaders are glob() and file(): glob() turns many files in a directory tree into entries, while file() turns one file, such as JSON or YAML, into one collection of entries. The official Content Loader API also allows custom build-time loaders and live loaders, but this page focuses on how local project files become importable modules inside the Astro build pipeline.

A content collection is a named set of structurally similar entries: blog posts, products, authors, recipes, documentation pages, or any other data type with a repeatable shape. Official docs describe a required loader and an optional schema as the two main parts of a collection definition. The loader answers the question “where does the data come from?”, while the schema answers “what should each entry look like?” Astro then uses that collection definition to validate entries, generate types, and let application code query content without manually wiring every source file.

Sources: packages/astro/src/content/vite-plugin-content-imports.ts, packages/astro/src/assets/utils/resolveImports.ts

Relevant Source Files

  • packages/astro/src/content/vite-plugin-content-imports.ts — Implements the Vite plugin that transforms flagged content and data entry imports into modules Astro can consume during development and build.
  • packages/astro/src/assets/utils/resolveImports.ts — Resolves local image references from content files into Vite-resolvable import IDs, including the special content image flag and importer query parameter.
  • configs/tsconfig.build.json — Defines the shared package build TypeScript configuration, including src as the root and dist as the output directory for publishable package builds.
  • packages/astro-prism/tsconfig.build.json — Shows a package-specific extension of the shared build config, including a virtual type declaration alongside source files.
  • packages/astro-rss/tsconfig.build.json — Shows a simple package build that inherits the shared TypeScript build configuration without overriding the include list.
  • .changeset/sharp-bags-build.md — Records a build-related fix where rendering errors during prerendering must fail astro build instead of silently emitting incomplete output.

Core Primitives

Astro’s docs divide loaders into build-time loaders and live loaders. Build-time loaders are objects with a load() method that runs during the build to fetch data and update the data store. They can also define a schema so entries can be validated and typed. For local content, Astro ships ready-to-use object loaders: glob() for many files and file() for a single file. For remote data, a project usually provides a custom object loader, uses a community loader, or supplies a simpler async loader function that returns entries.

The glob() loader is the normal choice when each entry is represented by its own file. Its documented options include pattern, base, generateId, and retainBody. pattern selects matching files, base sets the directory used for resolving those matches, generateId customizes the entry ID, and retainBody controls whether the original body is kept when appropriate. The supported local formats documented for this workflow include Markdown, MDX, Markdoc, JSON, YAML, and TOML, which lets the same collection system handle both content documents and structured data files.

The file() loader is the complementary primitive for data that already lives in one file. Instead of asking Astro to discover many source files, the loader reads one source and uses its contents to produce entries. This is useful for authors, product catalogs, navigation records, translation metadata, or any data set where the editorial format is more naturally one JSON, YAML, or TOML file than a directory full of documents. Both local loaders feed the same content layer, so downstream code can query collections rather than caring whether entries came from many files or one file.

System-to-Code Mapping

The repository code in astroContentImportPlugin() shows where content-layer data becomes Vite module code. The plugin computes content paths from the Astro config, collects supported content and data entry extensions from settings, and builds extension-to-entry-configuration maps. It then registers a Vite transform named astro:content-imports with a filter that only handles module IDs containing Astro’s content or data flags. This means content collection entries are not treated as arbitrary files: they are recognized through explicit import flags and converted into stable module exports.

Sources: packages/astro/src/content/vite-plugin-content-imports.ts

The transform path separates data entries from content entries. For data imports, the plugin reverses Vite’s symlink resolution so Astro can keep paths relative to the content directory, calls getDataEntryModule(), and emits module code exporting values such as id, collection, and data. The source comments clarify why data collections do not need the module cache used by content rendering. That distinction matters for loader users because structured data entries and renderable content entries share collection APIs, but renderable content has an additional rendering module path behind the scenes.

Sources: packages/astro/src/content/vite-plugin-content-imports.ts

The same source file also captures development invalidation concerns. Collection imports can depend on the collection config for parsing schemas, and config may depend on collection entries through references. The plugin therefore tracks invalidation across data, content, and config collection types and watches common modified events such as add, unlink, and change. In practical terms, editing a Markdown post, changing a JSON data file, or adjusting src/content.config.ts needs to invalidate the right virtual modules so development and builds see the current collection state.

Sources: packages/astro/src/content/vite-plugin-content-imports.ts

Import and Asset Flow

Content files can contain local image references, and those references need special handling once a loader has moved content into Astro’s data and module pipeline. imageSrcToImportId() receives an image src and the path of the content file that contained it. It first removes Astro’s image import prefix if the import came from the data store, ignores remote URLs, and ignores non-image extensions. For valid local image formats, it appends query parameters carrying Astro’s content image flag and, when available, the original importer path.

Sources: packages/astro/src/assets/utils/resolveImports.ts

That importer query parameter solves a subtle loader problem. A relative image path in Markdown is relative to the Markdown file, not to the generated module that will eventually import it. Once entries are collected into a single asset-oriented module, a plain relative path would no longer resolve from the original content file. By embedding the original importer path in the import ID, Astro gives the Vite plugin enough context to resolve the asset as if the import still came from the source content file. importIdToSymbolName() then turns the import ID into a stable generated symbol using Astro’s short hash helper.

Sources: packages/astro/src/assets/utils/resolveImports.ts

A typical local collection configuration starts with the public content APIs and then lets this lower-level pipeline do the import work:

import { defineCollection } from 'astro:content';
import { glob, file } from 'astro/loaders';
 
const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
});
 
const authors = defineCollection({
  loader: file('./src/content/authors.json'),
});

This example shows the author-facing contract rather than the internal implementation. Authors name a loader and, optionally, a schema. Astro resolves collection paths, extension handlers, data parsing, rendering modules, image imports, and invalidation through its content and Vite integration code. That division is important when debugging: if an entry is not discovered, first check the loader pattern or file path; if an entry is discovered but renders or imports assets incorrectly, the relevant code path may be the content import transform or the content image import resolver.

Build and Package Signals

The shared TypeScript build configuration establishes a simple convention for packages in this repository: source files live under each package’s src directory, build output goes to dist, and incremental TypeScript build metadata is written under dist/._cache/ts_build. That convention matters for content loader work because public packages and integrations need reproducible build outputs, and virtual modules or type declarations must be included deliberately when a package depends on them. The build config is not a content loader API, but it documents how source-level content features become distributable package code.

Sources: configs/tsconfig.build.json, packages/astro-prism/tsconfig.build.json, packages/astro-rss/tsconfig.build.json

The package-specific build configs show two useful patterns. packages/astro-rss/tsconfig.build.json simply extends the shared build config, which is enough when a package’s publishable TypeScript inputs match the shared src convention. packages/astro-prism/tsconfig.build.json extends the same base but includes both ./src and ./virtual.d.ts, showing how packages that expose virtual modules or additional ambient declarations include those files in the build. Content collections similarly rely on generated and virtual surfaces, so build inclusion rules are part of keeping public APIs coherent.

Sources: packages/astro-prism/tsconfig.build.json, packages/astro-rss/tsconfig.build.json

The changeset in .changeset/sharp-bags-build.md records a deployment-adjacent build fix: prerendering errors in a workerd environment were previously swallowed, causing astro build to exit successfully while emitting truncated HTML. The fix described there buffers the response body so streaming errors are caught and surfaced as build failures. While this file is about the Cloudflare adapter rather than content loaders, it reinforces an important expectation for content-driven builds: loader, rendering, and prerender failures should be visible to the build process instead of producing incomplete site output.

Sources: .changeset/sharp-bags-build.md

Debugging Loader Workflows

When a glob() collection is empty, start with the author-facing inputs: confirm base points at the intended directory, confirm pattern matches the target file names, and confirm the file extension is supported for the type of content you expect. If entries appear but have unexpected IDs, inspect any generateId option before looking lower in the stack. When a file() collection produces the wrong shape, check the source file’s structure and the collection schema, because the loader can only expose the entries it can derive from that single data source.

When imported assets fail from Markdown or other content files, remember that Astro rewrites eligible local image paths into Vite import IDs with a content image flag. Remote URLs intentionally return no import ID, and unsupported extensions are ignored by the resolver. If a local image path is correct relative to the content file but fails after collection processing, the importer context attached by imageSrcToImportId() is the key detail to understand. The resolver preserves that original source-file context so downstream Vite handling can resolve the asset correctly.

Sources: packages/astro/src/assets/utils/resolveImports.ts

For development refresh problems, the content import plugin is the relevant mental model. Content and data entries are transformed only when their Vite IDs carry Astro’s content or data flags, and collection changes need to invalidate generated import modules. If changing src/content.config.ts, adding a content file, deleting a data file, or editing a referenced entry does not seem to update the app, think in terms of collection config, content modules, data modules, symlink reversal, and watcher invalidation rather than ordinary static imports.

Sources: packages/astro/src/content/vite-plugin-content-imports.ts

Next Steps

Use glob() when editorial work naturally creates one file per entry, and use file() when a single structured file is the source of truth. Add schemas to collections when you want validation, autocomplete, and generated type safety. If your data comes from a CMS, database, or API, treat the local loaders as examples of the loader contract and build a custom object loader that writes compatible entries into the content layer. Next, read the content collections page for querying patterns, then the Markdown and MDX content page for renderable document behavior, and the images and assets page for deeper asset pipeline details.