
Blog Underlying Stack Update Log
Notes and pitfalls from upgrading the blog's underlying stack from Astro 5 + Tailwind 3 + DaisyUI 4 to Astro 7 + Tailwind 4 + DaisyUI 5
A few days ago, in a moment of madness and boredom, I suddenly wanted to update the underlying libraries of this blog. Plus, being someone who is purely idle and loves chasing the latest versions, this is just a write-up of my own notes. (The page is written very simply, for reference only — update with caution.)
It also happens to be the blog’s first anniversary, so consider this a “birthday celebration 😂”
First, the previous situation: it was an Astro 5 + Tailwind 3 + DaisyUI 4 architecture, and after the upgrade it became Astro 7 + Tailwind 4 + DaisyUI 5, with all the Astro companion components on the latest version. But note that when I was writing this, TypeScript 7 still didn’t support Astro, so I only used the latest TypeScript 6.
Let me mention the browser compatibility issues the update causes: Tailwind v4 requires Chrome ≥111, Edge ≥111, Firefox ≥128, Safari ≥16.4; the update will break compatibility on older devices.
Update Begins
Simple Version Update
package.json
// Added dependencies "@astrojs/markdown-remark": "^7.2.2" // standalone markdown processing "@iconify/tailwind4": "^1.2.3" // Tailwind 4 version of Iconify "@tailwindcss/vite": "^4.3.3" // Tailwind Vite plugin (replaces @astrojs/tailwind)
// Removed dependencies "@astrojs/tailwind": "^6.0.2" // no longer needed "@iconify/tailwind": "^1.2.0" // replaced with the tailwind4 version "@typescript-eslint/parser": "^8.48.1" // no longer needed "sass-embedded": "^1.93.3" // SCSS → CSS migration "medium-zoom": "^1.1.0" // removed "remark": "^15.0.1" // replaced by @astrojs/markdown-remark
"typescript": "^5.9.3" "typescript": "^6.0.3"
// Everything else is a direct version bumppnpm-workspace.yaml
allowBuilds: sharp: true swup: false unrs-resolver: true // required by Astro 7astro.config.mjs Astro config migration
import tailwind from "@astrojs/tailwind"; const playformCompress = (await import("@playform/compress")).default; // sync import import tailwindcss from "@tailwindcss/vite"; // Tailwind 4 uses the Vite plugin import { unified } from "@astrojs/markdown-remark"; // standalone markdown processor
export default defineConfig({ integrations: [ // ... other integrations tailwind({ configFile: "./tailwind.config.mjs", }), // Tailwind 4 no longer needs an integration playformCompress(), playformCompress({ Image: false, // must disable, sharp version incompatible JavaScript: false, // JS handled by terser }), ], markdown: { remarkPlugins: [...], rehypePlugins: [...], processor: unified({ // new markdown processor API remarkPlugins: [...], rehypePlugins: [...], }), }, vite: { plugins: [tailwindcss()], // Tailwind 4 Vite plugin build: { cssMinify: "esbuild", // required! lightningcss doesn't support @apply }, css: { preprocessorOptions: { scss: { api: "modern-compiler" }, // remove SCSS config }, }, }, style: { scss: { includePaths: ["./src/styles"], // remove SCSS config }, },});cssMinify: "esbuild": This is a mandatory option for the Tailwind 4 migration. Vite uses lightningcss to minify CSS by default, but it doesn’t recognize Tailwind’s@applydirective and will output about 120+ warnings. esbuild stays silent on unknown at-rules.@playform/compressImage disabled: The bundled sharp 0.34.5 throws acolourspace: parameter space not seterror when processing PNGs without an ICC profile. It’s recommended to pre-optimize images in the project, so no second compression is needed.@playform/compressJavaScript disabled: JS minification is already handled by@rollup/plugin-terser, avoiding duplicate processing.
Tailwind 4 switches to a CSS-first configuration and no longer needs tailwind.config.mjs: it’s recommended to refer to the Tailwind 4 official upgrade guide↗, which includes an update plugin that can theoretically save you some of the deprecated/modified CSS style adaptations (mentioned later).
// delete tailwind.config.mjs /** @type {import('tailwindcss').Config} */ export default { content: ["./src/**/*.{astro,html,js,md,mdx,svelte,ts,tsx,vue}"], plugins: [daisyUI, typography, addDynamicIconSelectors()], daisyui: { themes: ["winter", "night"], darkTheme: "night", logs: false, }, };Create src/styles/tailwind.css
@import "tailwindcss";@import "./global.css"; /* import custom global styles */
@theme { --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";}
@plugin "@tailwindcss/typography";@plugin "@iconify/tailwind4";@plugin "daisyui" { themes: winter --default, night --prefersdark; logs: false;}@themedirective replacestheme.extend— custom theme values are defined in the@themeblock@plugindirective replacesplugins: [...]— Tailwind plugins are imported via@plugin- No
contentconfig needed — Tailwind 4 auto-detects template files @importreplaces@tailwind base/components/utilities
DaisyUI config changes
// DaisyUI v4 (tailwind.config.mjs) daisyui: { themes: ["winter", "night"], darkTheme: "night", logs: false, }
// DaisyUI v5 (tailwind.css)@plugin "daisyui" { themes: winter --default, night --prefersdark; logs: false;}Some breaking changes; see the official docs↗ for more.
| Feature | v4 | v5 |
|---|---|---|
| Config method | JS object | CSS @plugin directive |
input-bordered | ✅ exists | ❌ removed, input has border by default |
btn-outline | normal | behavior slightly different |
shadow-sm | ✅ | ❌ changed to shadow-xs |
collapse component | HTML structure | ❌ removed, must implement yourself |
- Many component style behaviors changed subtly in DaisyUI 5; each component needs to be checked individually
Detailed Update Adaptations
Content Layer API changes
Astro 7’s Content Layer API is significantly different from Astro 5’s.
Create src/content.config.ts; you can directly rename the original src/content/config.ts for clarity.
import { defineCollection } from "astro:content";import { z } from "astro/zod";import { glob } from "astro/loaders";
const blog = defineCollection({ loader: glob({ pattern: ["**/*.{md,mdx}"], base: "./src/content/blog", }), schema: z.object({ title: z.string(), description: z.string(), pubDate: z.coerce.date(), updated: z.coerce.date().optional(), // ... other fields }),});
export const collections = { blog };| Old API (Astro 5) | New API (Astro 7) | Explanation |
|---|---|---|
blog.slug | blog.id | slug renamed to id |
blog.render() | render(blog) | render becomes a standalone function |
getCollection() from astro:content | unchanged | but you must import render separately |
Code migration example
import { getCollection } from "astro:content"; import { getCollection, render } from "astro:content";
// slug → id <a href={`/blog/${post.slug}`}> <a href={`/blog/${post.id}`}>
// render() call const { Content } = await blog.render(); const { Content } = await render(blog);
// getStaticPaths params: { slug: blog.slug }, params: { slug: blog.id },CSS System Migration: SCSS → Tailwind CSS
The project originally used SCSS (global.scss) and now migrates to plain CSS (global.css), leveraging Tailwind 4’s @apply + CSS nesting.
File structure changes
src/styles/global.scss (719 lines, SCSS)tailwind.config.mjs (JS config)src/styles/global.css (1207 lines, plain CSS)src/styles/tailwind.css (Tailwind 4 entry)@tailwinddirective removed — global styles no longer need@tailwind base/components/utilities- SCSS feature replacements:
@mixin/@include→ CSS@layer+ reusable classes$variable→ CSS custom property--variable- nested selectors → native CSS Nesting (widely supported)
@applystays available — you can still use@applyinglobal.css
astro.config.mjs remove SCSS config (partly modified above already)
style: { scss: { includePaths: ["./src/styles"], },},vite.css.preprocessorOptions.scss: { api: "modern-compiler",},The Tailwind component switching mentioned above can theoretically be done with the official plugin, but be aware that the plugin seems to do simple search-and-replace. If you used a matching variable name in some files, it will be wrongly replaced too. It’s recommended to re-build and retry before committing.
Other
js-yaml ^4 → ^5
import yaml from "js-yaml";import * as yaml from "js-yaml";Waline component’s define:vars syntax change (Astro 7 doesn’t support inline referencing of Astro.props.X in define:vars)
serverURL: Astro.props.serverURL,lang: Astro.props.lang ?? "zh",// destructure first, then pass the variable nameconst { serverURL, lang = "zh" } = Astro.props;// reference the variable directly in define:varsserverURL,lang,Other than that, Astro 7’s whitespace rules changed multi-line inline elements: the old version auto-generated spaces for line breaks; the new JSX-style version does not generate spaces, so inline text is directly concatenated. The most noticeable case is probably that the license author and the license name no longer have a space between them. There are other places you can adjust yourself.
Also, regarding fonts: Tailwind seems to have updated its fonts in v4 (or system fonts were moved to the front). To keep the v3 fonts, you can add the following to tailwind.css:
@theme { --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";}One more thing: previously it seemed that md:xx lg:xx would cause some components to display with the md screen style even on the lg screen. After the update this is fixed, so the lg screen correctly displays the component style, which can make things appear a slightly different size. To preserve the old behavior you can adjust some components (I just changed lg to 2xl and it was fixed XD).
Yet another thing: you can also add to global.css:
@layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentColor); }
input::placeholder, textarea::placeholder { color: var(--color-gray-400); } /* (optional) cursor style for clickable elements */ button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; } /* (recommended) force uniform top/bottom margin for hr, mainly the license area */ hr { margin-block: 1rem !important; }}If you updated pagefind from 1.4.0 to 1.5.x, you may also need to update the search page, which may report “Pagefind: WASM Error (No pointer)”.
- bundlePath resolution failure: the
@pagefind/default-uipackage determines the location of pagefind.js by checkingdocument.currentScript. But when loaded via dynamicimport()(and since Astro bundles via Vite),document.currentScriptis always null. So bundlePath becomes undefined. - The build output uses a wrong baseUrl: the current
dist/build passesbaseUrl: "/pagefind/", which constructs wrong links like/pagefind/blog/article-path(it should use/as the baseUrl) — but this is not the root cause; the root cause is the missing bundlePath.
So the fix is:
const search = new PagefindUI({ element: "#pagefind-search", bundlePath: "/pagefind/", showImages: false, ...});If you want, you can also migrate the pagefind default-ui to component-ui; tinker with it if you’re bored.
Postscript
Damn, after deploying the update, no matter how I refreshed it kept erroring, yet it worked fine locally. I thought it was a component issue of mine, but it turned out to be a browser cache problem. I spent a long time deleting the cache; at first I thought it was on the Vercel/Cloudflare side, but then I found that only Edge couldn’t display it properly. I fiddled with the Service Worker for a while and still couldn’t fix it, until finally I cleared everything directly in DevTools → Application → Storage and it worked. Took me a whole day…
Summary
- Update all dependency versions in
package.json - Update
pnpm-workspace.yamlto addunrs-resolver: true - Run
pnpm install - Create
src/styles/tailwind.cssas the Tailwind 4 entry - Delete
tailwind.config.mjs - Migrate
global.scss→global.css(remove@tailwinddirective) - Update
astro.config.mjs(Tailwind Vite plugin, Markdown processor, cssMinify, etc.) - Create
src/content.config.ts - Batch-replace Tailwind utility classes across all
.svelteand.astrofiles - Replace
blog.slug→blog.id,blog.render()→render(blog) - Run
pnpm buildto verify a successful build
In closing: I’m exhausted, this was purely thankless busywork, just out of sheer idleness. My personal feeling is that the build was just a bit faster — on Vercel the original build time of two and a half minutes is now just under two minutes. But what does that have to do with me… the range of supported devices actually got smaller.