The Astro upgrade that removed all my spacing
- #astro
- #tailwind
- #accessibility
- #tooling
I upgraded this site from Astro 5 to Astro 7 expecting the content collections migration to be the awkward bit. It wasn’t.
The thing that actually cost me time was Tailwind. Every spacing utility on the site stopped working, while the build and type checks stayed green.
The short version
I moved the site through two major Astro versions, changed the content collections API, moved to Tailwind CSS 4, replaced the old Tailwind integration, and switched the Markdown pipeline. The most useful lesson was less glamorous: a green build does not prove that the page looks right.
What I checked first
Astro 5 came out in 2024. By the time I did this upgrade, Astro 6 and Astro 7 were both in the gap between the version I had installed and the version I wanted to use.
I read the upgrade notes for both releases before changing the code. That sounds obvious. It is also easy to skip when the command to update a dependency is sitting right there.
The other early check was the integrations. The old @astrojs/tailwind integration does not work with Astro 6 or later, so Tailwind needed to move to a different setup. Astro 7 also needs Node.js 22.12 or later. This site was still on Node 18, which meant updating three places together:
.nvmrcnetlify.toml- the
enginesfield inpackage.json
If the deploy configuration is missed, the build can work locally and fail on the server. That is a more annoying place to find out.
The content collections change
Astro 6 removed the legacy content collections API. The replacement is not conceptually difficult, but it touches several parts of the site:
- Move
src/content/config.tstosrc/content.config.ts. - Add a loader to each collection.
- Change
entry.slugtoentry.id. - Change
entry.render()torender(entry).
Without a loader, Astro does not find the files.
The collection configuration
Astro 5
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({ type: 'content', schema: z.object({ title: z.string(), }),});Astro 7
import { defineCollection } from 'astro:content';import { glob } from 'astro/loaders';import { z } from 'astro/zod';
const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog', }), schema: z.object({ title: z.string(), }),});There was a quieter change around the body property too. It can now be empty, so code that counts words in a post needs a default before it starts counting. I also moved the schema import from astro:content to astro/zod to avoid the warning.
That was the kind of change I expected from the upgrade: explicit, local, and fairly easy to check.
Tailwind was the real problem
Tailwind CSS 4 moved more of its configuration into CSS. The setup on this site became:
@import 'tailwindcss';@custom-variant dark (&:where(.dark, .dark *));The first line replaces the old Tailwind directives. The second keeps class-based dark mode working.
The migration itself was not the interesting part. The cascade was.
The spacing disappeared
My global reset looked like this:
* { margin: 0; padding: 0;}That had been harmless with Tailwind 3. The utility classes won when they needed to, and the site looked normal.
Tailwind 4 puts its utility classes inside a CSS layer. The reset was outside a layer, so it won the cascade regardless of the utility class’s specificity. p-4, p-6, my-6, px-3: they all collapsed to 0px.
Nothing was syntactically wrong. The build passed. The type check passed. The page was just wrong.
I found it by opening the site in a browser and measuring an element by hand. The fix was small:
@layer base { * { margin: 0; padding: 0; }}A green build is not a visual test
The build could tell me that the CSS was valid. It could not tell me that every spacing utility had stopped having an effect. I had to look at the page.
That changed how I checked the rest of the migration. I stopped treating astro check and the production build as the finish line. They were useful checks, but they were only two checks.
The other Astro 7 changes
Astro 7 brings a new Markdown processor, Sätteri, and a stricter compiler. I expected Expressive Code to need work because it depends on a rehype plugin. It did not; it worked with the new processor.
I also added Expressive Code for the code blocks on the site. It gives readers a copy button, diff-style marks, editor- or terminal-style frames, and collapsible sections. I checked the production bundle rather than guessing: it added 18.6 kB of CSS and 2.5 kB of JavaScript.
One of my own CSS rules then competed with Expressive Code’s pre styling. Both selectors had the same specificity, so the later declaration won. I narrowed my selector with :not() rather than relying on declaration order.
The icons were a smaller decision. Emoji did not render consistently between systems, so I replaced them with ten local SVGs. That added 4 kB and no icon dependency. Ten icons did not justify another library.
Accessibility and drafts
The icon change exposed an accessibility problem. The callout emoji had aria-hidden="true", which meant a screen reader skipped it. That was fine when the text beside it carried the same meaning. It was not fine when colour and the emoji were the only difference between a warning and a tip.
Each callout now has a hidden text label such as Warning:, Tip:, or Danger:. A sighted reader does not see it, but a screen reader gets the information the colour was carrying.
I also changed how drafts are excluded. My first approach used a draft field in frontmatter and filtered it wherever posts were listed. I filtered it in three places and forgot a fourth. A draft got a public URL and a sitemap entry before I noticed.
The better solution was to put drafts in _drafts and exclude that directory from the production loader:
loader: glob({ pattern: import.meta.env.DEV ? '**/*.{md,mdx}' : ['**/*.{md,mdx}', '!_drafts/**'], base: './src/content/blog',}),In development, I can still preview drafts. In production, they are not in the collection at all.
How the upgrade actually went
The migration
Read the upgrade notes
I read the notes for Astro 6 and Astro 7 before changing the code.
Update the dependencies
I moved to Astro 7, MDX 7, and Tailwind CSS 4, then removed the old Tailwind integration.
Fix the code and CSS
I changed the content collections API and fixed the reset that was beating Tailwind utilities.
Open the site
I built the site, read the generated HTML, and checked the pages in a browser.
What I would do next time
- Upgrade one major version at a time if the site is important enough to justify the extra release.
- Check integrations before changing application code.
- Open the site before trusting a green build.
- Inspect the generated HTML and CSS, not just the source files.
- Keep data out of the wrong collection rather than remembering to filter it in several places.
The site after the upgrade
From 5.15, through two major versions
From astro check and the build
Ten SVG files, and no icon library
The site ended up with two more dependencies than it started with. Expressive Code and the new Markdown package account for both. The icons need no library, and the remaining dependencies are still actively maintained. That is a trade I would make again.
The part I would not repeat is waiting for a green build to tell me the page was fine. It could not see the missing spacing. I had to do that bit myself.
See Astro’s v7 upgrade guide and Tailwind’s upgrade guide if you are making the same move.

About Charly Webster
Charly Webster is Head of AI Engineering at M&S, leading a centralised AI Product Engineering Centre of Excellence. Read more about Charly or follow him on LinkedIn.