diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..c2755cc
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:24-alpine
+WORKDIR /app
+COPY package*.json ./
+RUN npm i
+COPY . .
+ENV PUBLIC_PB_URL=http://pb:8080
+ENV PUBLIC_URL=http://localhost:4321
+
+RUN npm run build
+
+EXPOSE 4321
+# CMD ["npm", "run", "dev", "--", "--host"]
+CMD [ "npm", "run", "preview", "--", "--host" ]
diff --git a/web/src/pages/tags/[tag].astro b/web/src/pages/tags/[tag].astro
new file mode 100644
index 0000000..d09fa03
--- /dev/null
+++ b/web/src/pages/tags/[tag].astro
@@ -0,0 +1,39 @@
+---
+import Base from "@layout/Base";
+import { Recipe } from "@tmlmt/cooklang-parser";
+import { authPB } from "@data/pb";
+import Card from "@component/index/card";
+
+const { tag } = Astro.params
+const pb = await authPB()
+
+const records = await pb.collection('recipes').getFullList({
+ filter: `cooklang~"- ${tag}"` // what a hack lmao but it works
+})
+
+const recipes = records.map(r => new Recipe(r.cooklang))
+const ids = records.map(r => r.id)
+const images = await Promise.all(
+ records.map(r => pb.files.getURL(r, r.images[0]).substring(21)) // get first image from each recipe as a cover image
+)
+---
+
+
+ {recipes.length} { recipes.length == 1 ? "Recipe" : "Recipes" } with: {tag}
+
+ {tags.length} Tags +
+ { + (tags ?? []).map((t, i) => ( + //{t.name} -> {countsPerTag[i]} {countsPerTag[i] == 1 ? "Recipe" : "Recipes" }
+ + {t} ({countsPerTag[i]}) +