Article · 2024-01-01

From 404 to 200: Solving Missing CSS on Astro with GitHub Pages

The paths for these CSS files looked like this:

https://geyuxu.com/_astro/_slug_.BvCO7WHQ.css

The server couldn't find the CSS files Astro had generated. The question was why.

Investigation: Two Dead Ends

Hypothesis 1: Jekyll

GitHub Pages uses Jekyll by default. Jekyll ignores all files and directories beginning with an underscore--_posts, _includes, and so on. Astro's build output places CSS under _astro, which looked like the obvious cause.

Fix attempted: I added an empty .nojekyll file to the repository root. This tells GitHub Pages to skip Jekyll and serve the directory as plain static content.

Result: The CSS files under _astro still returned 404.

The .nojekyll file should have disabled Jekyll entirely. Something else was blocking access.

Hypothesis 2: Directory Access

Perhaps _astro was inaccessible because it lacked an index.html.

Fix attempted: I created an empty index.html inside dist/_astro/ and redeployed.

Result: No effect. We were requesting specific files, not browsing a directory.

The Real Cause

Disabling Jekyll was necessary but not sufficient. After further research, I found a more fundamental constraint:

GitHub Pages blocks direct web access to any file whose name starts with an underscore. This rule appears to operate at the server level, independently of Jekyll.

Astro's generated CSS filenames--such as _slug_.BvCO7WHQ.css--also start with an underscore. The _astro directory name was one problem; the filenames themselves were another. .nojekyll addressed neither.

Fixing the Build Output

Since the platform rule can't be changed, the solution is to configure Astro to stop generating underscore-prefixed filenames.

Astro uses Vite internally and exposes Vite's configuration API through astro.config.mjs. The relevant option is vite.build.rollupOptions.output.assetFileNames, which controls the output path and naming pattern for assets such as CSS and images.

Configuration

Open astro.config.mjs in your project root and add the vite configuration object:

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

export default defineConfig({
  site: 'https://geyuxu.com',
  // ... other configs
  vite: {
    build: {
      rollupOptions: {
        output: {
          // Modify asset file naming rules to avoid underscore prefixes
          assetFileNames: (assetInfo) => {
            const info = assetInfo.name.split('.');
            const ext = info[info.length - 1];
            const name = info.slice(0, -1).join('.');
            // If filename starts with underscore, replace with 'assets-'
            const finalName = name.startsWith('_') ? name.replace(/^_/, 'assets-') : name;
            return `_astro/${finalName}.[hash].${ext}`;
          }
        }
      }
    }
  },
  // ... other configs like markdown, etc.
});

Simpler Alternative

If renaming is the only goal, a static pattern avoids the conditional entirely:

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

export default defineConfig({
  // ...
  vite: {
    build: {
      rollupOptions: {
        output: {
          // A simpler pattern for all assets
          assetFileNames: 'assets/[name].[hash][extname]',
          // JS entry files
          entryFileNames: "assets/entry.[hash].js",
          // JS code-splitted chunks
          chunkFileNames: "assets/chunk.[hash].js",
        }
      }
    }
  }
});

This places all assets in an assets/ directory using Rollup's [name] and [hash] placeholders, which naturally avoids the leading underscore.

Verification

After rebuilding and redeploying:

npm run build
npm run deploy

CSS now loaded from paths like assets-slug_.BvCO7WHQ.css with HTTP 200. Confirm with:

curl -I https://geyuxu.com/_astro/assets-slug_.BvCO7WHQ.css

Response:

HTTP/2 200 
content-type: text/css; charset=utf-8

Lessons

Platform constraints run deeper than surface tools. .nojekyll disables the Jekyll build step; it does not override the server's file-serving rules. Understanding what each layer of a platform actually controls matters more than reaching for the standard fix.

Fix at the source. Configuring the build to produce compliant output is cleaner than patching the deployment environment. Vite's assetFileNames option gave full control without touching any platform configuration.

Underscores carry implicit meaning. In Jekyll, in Node.js conventions, and apparently in GitHub Pages' serving rules, a leading underscore marks a file as special or private. When deploying to opinionated platforms, naming choices in the build tool have real consequences.

© 2026 Yuxu Ge ·