[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"contentNavigation":3,"source-vueschool":4,"newsletter-stats":11,"articles-feed-\u002Fsources\u002Fvueschool-1--019d6bd5-87de-77ef-ac92-98aa56cda920":13,"$fQa3xUpnRDPNiFFu2QS4K-DAjYmsapeR9fNhPNBdpkOc":-1,"home-tags":135},[],{"articleCount":5,"category":6,"id":7,"name":8,"slug":9,"sourceType":9,"url":10},5,null,"019d6bd5-87de-77ef-ac92-98aa56cda920","VueSchool","vueschool","https:\u002F\u002Fvueschool.io",{"confirmedCount":12},409,{"items":14,"page":133,"pageSize":134,"totalCount":5},[15,45,73,93,115],{"content":16,"createdAt":17,"id":18,"image":19,"isAffiliate":20,"isPublished":20,"publishedAt":21,"slug":22,"sourceId":7,"sourceName":8,"sourceType":9,"summary":23,"title":24,"updatedAt":25,"url":26,"urlHash":27,"tags":28},"This entry is part 1 of 2 in the series Vue Component DesignGuess what!? We’ve just published a new course all about component design patterns in Vue.js. It’s sure to improve your Vue codebase with battle-tested patterns for scalability and maintainability. We created the course primarily for beginners to help them up their component development skills, but even more learned Vue devs might find a new pattern or two. Go checkout the course now or keep reading for an overview of some of the patterns discussed in the videos. 1. The Branching Component Design Pattern Extract complex conditional rendering into separate components. Instead of multiple v-if branches in one component, create dedicated components for each state. The resulting code is easier to read and maintain over time. &lt;!-- Before --&gt; &lt;template&gt; &lt;div&gt; &lt;div v-if=&quot;loading&quot;&gt;Loading...&lt;\u002Fdiv&gt; &lt;div v-else-if=&quot;error&quot;&gt;Error: {{ error }}&lt;\u002Fdiv&gt; &lt;div v-else&gt; &lt;!-- Complex content --&gt; &lt;\u002Fdiv&gt; &lt;\u002Fdiv&gt; &lt;\u002Ftemplate&gt; &lt;!-- After --&gt; &lt;template&gt; &lt;div&gt; &lt;LoadingState v-if=&quot;loading&quot; \u002F&gt; &lt;ErrorState v-else-if=&quot;error&quot; :message=&quot;error&quot; \u002F&gt; &lt;ContentState v-else :data=&quot;data&quot; \u002F&gt; &lt;\u002Fdiv&gt; &lt;\u002Ftemplate&gt; 2. Slots and Template Props Design Pattern Whenever you have a prop that gets passed directly to the template, that’s a good sign you should be using a slot instead. Why? They serve the same purpose, but the slot allows more flexibility. &lt;!-- AppButton.vue --&gt; &lt;!--Before--&gt; &lt;script setup&gt; defineProps({ label: { type: String, default: &quot;Click Me&quot; } }) &lt;\u002Fscript&gt; &lt;template&gt; &lt;button class=&quot;btn&quot;&gt; &lt;!-- the label makes a beeline to the template and makes no stops along the way--&gt; {{ label }} &lt;\u002Fbutton&gt; &lt;\u002Ftemplate&gt; &lt;!--After--&gt; &lt;template&gt; &lt;button class=&quot;btn&quot;&gt; &lt;!-- ahh, that&#039;s better now we can pass in markup, icons, components whatever--&gt; &lt;slot&gt;&lt;\u002Fslot&gt; &lt;\u002Fbutton&gt; &lt;\u002Ftemplate&gt; Thanks Michael Thiessen for this wonderful pattern and a memorable name to boot! 3. List with ListItem Component Pattern Separate list logic and item display into distinct components. This allows for bundling up list empty state, content, controls, and more into a contained List component. Extracting each item in the list to it’s own ListItem component, makes the List component easy to read, and provides more focus when developing and styling each item. &lt;!-- UsersList.vue --&gt; &lt;script setup&gt; const props = defineProps({ users: { type: Array, required: true } }); const filter = ref(&quot;&quot;) const filteredUsers = computed(()=&gt;{ \u002F\u002F filter logic here }) &lt;\u002Fscript&gt; &lt;template&gt; &lt;div&gt; &lt;!-- List Controls --&gt; &lt;UserFilters v-model=&quot;filter&quot; \u002F&gt; &lt;!-- List Content --&gt; &lt;ul v-if=&quot;filteredUsers.length&quot;&gt; &lt;!-- items extracted to their own component (see below) --&gt; &lt;UserListItem v-for=&quot;user in filteredUsers&quot; :key=&quot;user.id&quot; :user=&quot;user&quot; \u002F&gt; &lt;\u002Ful&gt; &lt;!-- Empty State--&gt; &lt;EmptyState v-else message=&quot;No users found&quot; \u002F&gt; &lt;\u002Fdiv&gt; &lt;\u002Ftemplate&gt; &lt;!-- UserListItem.vue --&gt; &lt;script setup&gt; defineProps({ user: { type: Object, required: true } }) &lt;\u002Fscript&gt; &lt;template&gt; &lt;li class=&quot;user-item&quot;&gt; &lt;img :src=&quot;user.avatar&quot; \u002F&gt; &lt;span&gt;{{ user.name }}&lt;\u002Fspan&gt; &lt;\u002Fli&gt; &lt;\u002Ftemplate&gt; 4. Smart vs Dumb Components Pattern Separate data handling from presentation. Smart components manage logic and data fetching, dumb components simply take in props to handle display. Furthermore, base components provide the fundamental building blocks of your application (such as cards, buttons, etc) and by convention start with v, base, or app. &lt;!-- Smart Component --&gt; &lt;script setup&gt; import { ref } from &#039;vue&#039; const users = ref([]) const loading = ref(true) async function fetchUsers() { users.value = await api.getUsers() loading.value = false } function createUser(){ \u002F\u002F open modal to create user or whatever } &lt;\u002Fscript&gt; &lt;template&gt; &lt;AppButton @click=&quot;createUser&quot; &gt;Create User&lt;\u002FAppButton&gt; &lt;UserList :users=&quot;users&quot; :loading=&quot;loading&quot; \u002F&gt; &lt;\u002Ftemplate&gt; &lt;!-- Dumb Component --&gt; &lt;script setup&gt; defineProps({ users: Array, loading: Boolean }) &lt;\u002Fscript&gt; &lt;template&gt; &lt;div class=&quot;user-list&quot;&gt; &lt;!-- Pure presentation logic --&gt; &lt;\u002Fdiv&gt; &lt;\u002Ftemplate&gt; 5. Form Component Design Pattern v-model on native input fields is awesome! We can do similar with whole forms. This approach accounts for the way users interact with forms: they expect committed data only after click of submit. It also works like a charm with native input validation as the submit event never fires until native validation passes. &lt;script setup&gt; \u002F\u002F support v-model const user = defineModel() \u002F\u002F clone the modelValue to local data \u002F\u002F and provide a fallback user if none provided const form = ref(clone(user) || { name: &#039;&#039;, emaill: &#039;&#039; }) \u002F\u002F only update the modelValue when the form is submitted function handleSubmit() { user.value = clone(form.value); } \u002F\u002F Reset form when prop changes watch(user, () =&gt; form.value = clone(user.value)) const clone = (obj)=&gt; JSON.parse(JSON.stringify(obj)) &lt;\u002Fscript&gt; &lt;template&gt; &lt;form @submit.prevent=&quot;handleSubmit&quot;&gt; &lt;!-- Bind the form inputs to the local data instead of the modelValue --&gt; &lt;input v-model=&quot;form.name&quot; required\u002F&gt; &lt;input v-model=&quot;form.email&quot; \u002F&gt; &lt;!-- Bonus! You can even have dynamic submit button labels based on the modelValue passed in--&gt; &lt;button type=&quot;submit&quot;&gt; {{ user ? &#039;Update&#039; : &#039;Create&#039; }} User &lt;\u002Fbutton&gt; &lt;\u002Fform&gt; &lt;\u002Ftemplate&gt; Looking for More Patterns? We cover all these patterns discussed above in more detail in our course: Vue Component Design. Plus get a preview of some more advanced patterns like the Tightly Coupled Components Pattern, Recursive Components, and Lazy Dynamic Components. Besides our own course, Michael Thiessen’s Clean Components Toolkit is a great resource for further exploring not only component patterns but also patterns for stores, composables, and more.","2026-05-14T12:00:11.684Z","019e265b-cf92-75c7-bb8d-65f5cb9a819d","https:\u002F\u002Fblog.vueschool.io\u002Fwp-content\u002Fuploads\u002F2024\u002F12\u002FVS_5-Component-Design-Patterns-to-Boost-Your-Vue.js-Applications-1.png",true,"2026-05-14T08:00:26.000Z","5-component-design-patterns-to-boost-your-vuejs-applications","This article introduces a new course focused on component design patterns in Vue.js, aimed at enhancing scalability and maintainability of Vue applications. It outlines several design patterns, such as the Branching Component Design Pattern and the use of slots, which can help developers improve their code structure and readability.","5 Component Design Patterns to Boost Your Vue.js Applications","2026-05-14T12:00:21.296Z","https:\u002F\u002Fblog.vueschool.io\u002Fvuejs-tutorials\u002F5-component-design-patterns-to-boost-your-vue-js-applications\u002F?friend=MOKKAPPS","998659ef4f9a67af7ef263b481fc23eaa61736efd82e77946c3a11812701ff78",[29,33,36,39,42],{"color":30,"id":31,"name":32,"slug":32},"#10b981","019d6bd8-c9a8-7783-bd22-03145b355427","vue",{"color":30,"id":34,"name":35,"slug":35},"019e265b-f569-71d8-a02a-a17ec8180644","component-design",{"color":30,"id":37,"name":38,"slug":38},"019e265b-f571-7677-a537-2f7e96a490bf","scalability",{"color":30,"id":40,"name":41,"slug":41},"019e265b-f579-71cd-84b2-ac385b5225ae","maintainability",{"color":30,"id":43,"name":44,"slug":44},"019d6bd8-fba5-743f-8f9f-4e23c0b31581","tutorial",{"content":46,"createdAt":47,"id":48,"image":49,"isAffiliate":20,"isPublished":20,"publishedAt":50,"slug":51,"sourceId":7,"sourceName":8,"sourceType":9,"summary":52,"title":53,"updatedAt":54,"url":55,"urlHash":56,"tags":57},"I first started this article with the intention of teaching you how to combine Nuxt UI's Editor component with Jazz's collaborative rich text model to build a real-time shared editing experience in Nuxt. But then I realized that I was using AI to do a lot of the heavy lifting for me. Given the day and age we live in, you'd likely use AI to do the same thing. So instead of walking you through the text step by step, I'm going to take a different approach. Here's the plan: I'll start by explaining why I wanted to build this in the first place. Next I'll show off the final result and show you how you can use it for yourself if you'd like. Then I'll give you a list of takeaways on building with AI that: I learned during the process I've learned in the past but came up during this build Why I Thought Building the Nuxt UI Collaborative Editor Was a Good Idea Sure there are some off the shelf solutions for rich text editing with comments, but I'm pretty married to the Nuxt ecosystem. So I said, &quot;why not!? Let's give it a go!&quot; Furthermore, the control over the source code means I can also extend it in ways that I couldn't do with a pre-built solution. A Nuxt UI Collaborative Editor with Comment Support: the Final Result So what exactly did I build? Here's what's included in the final result: A Nuxt UI Editor component with a toolbar that handles the edit and display of rich text Support for multiple users to edit the same document together in real-time (courtesy of Jazz.) Support for comments tied to specific highlighted text in the editor Image upload, sizing, and alignment support A editing optimized layout that keeps the editor front and center while showing comments conditionally in a sidebar. The ability to add replies to comments The ability to mark comments as &quot;resolved&quot; 👉 You can demo the editor for yourself here How to Use the Nuxt UI Collaborative Editor with Comment Support If you want to use it for yourself, you can clone the demo repository, get an API key for Jazz at jazz.tools, and run it locally: git clone https:\u002F\u002Fgithub.com\u002Fdanielkellyio\u002Fnuxt-jazz-collaborative-editor-with-comments.git cd nuxt-jazz-collaborative-editor-with-comments npm install npm run dev Feel free to use it as a starting point for your own projects or copying and pasting the code existing codebases. A List of Takeaways on Building with AI While AI is a great tool, it's not 100% intuitive on how to use it to your advantage. unlearn.dev is a great resource for mastering the art of wielding it effectively. It's a full collection of workflows and strategies. Until then, here's a short list of takeaways on building with AI specfic to this project. Choosing the right tools for the job goes a long way. What do I mean? Well, I've never used Jazz before but given the exact use case it's meant to solve (multi-user real-time data syncing), it was a great fit for the job. (Thanks Alexander Opalic for turning me on to it!). The library's llms.txt was easy to pass on to my AI agent. I'm often tempted to reach for built-in REST api endpoints via Nitro, but it's worth exlporing new depenedencies when a better fit is available. Telling the Agent to iterate using the browser The editor setup was super simple, it's just the Nuxt UI editor component with it's companion Toolbar. Making it collaborative was also extremely easy with Jazz. However, things started getting a little harrier when I attempted to add comments support. The AI agent wanted to take the easiest path even though it didn't result in the best UI\u002FUX. So I told it: &quot;Iterate until it's a notion style commenting UI. localhost:3000 Use the browser.&quot; This forced it to: Do more of the work autonomously while I worked on other things Gave it tools to check it's own work And gave it a clearer goal without a lot of extra context creation on my part That leads me to... Mention existing projects that are similar to the one you're building Just like I mentioned &quot;Notion&quot;, if there's another popular solution similar to what you're attempting, defitintely mention it. The process let's you get more thoughts out of your head and into the context without having to spell it all out. Do note of course this only works when the tool you're mentioning are big enough that the model knows a thing or 2 about them. Screenshots of lesser known tools go a long way too! Work with different agents at the same time on non-overlapping tasks Yes, I know that we can work with multiple agents with worktrees or in the cloud on different branches, and so on. But it can still be more back and forth than necessary and some merge conflicts that take time to resolve. To help me keep my focus, while not just sipping on coffee and waiting for the agent to finish, I'll often work with different agents at the same time on non-overlapping tasks. These are tasks that both relate to the current objective and exist in the same branch but I know won't touch the same lines of code. For example, during this build, I worked with one agent on the image upload support while simultaneously working on some of the comment UI that I knew was pretty isolated. I've also found this helpful when building homepages or landing pages with multiple sections. Each section will get it's own component and I can jump back and forth between agents on each section to dial them in without worrying about conflicts. Look for opportunities for smart abstraction It didn't take looking at the code to know that Jazz isn't purpose built for use with Vue. It has Svelte docs and React docs but non for my favorite framework. So experience told me that my code would be DRY-er and more maintainable if one of the first tasks I undertook was to create a reactive wrapper around the Jazz API. So after the initial documention collaboration was working, I instructed the agent: &quot;could we make a reusable composable for wrapping jazz models with Vue reactive state?&quot; Of course, it was very obliging and created a useJazzReactiveState composable that ended up being used for both comments and document title syncing. Asking the agent to commit along the way Sometimes I'm happy with the state of the code but I'm working in a view that doesn't give me easy access to the terminal (for example, cursor's new Agent Window—which is awesome by the way!). So instead of changing context, I'll simply ask the agent to commit the changes in the same breath I tell it to continue working on the next new task. It's an easy thing but a quick way to stay in the flow. I did this on many occasions throughout this build. Don't be afraid to abort and start over At one point, I attempted to add supprt for viewing different users cursors (like in Notion or Google docs). The agent got a great rough draft of it, but it was too buggy and added complexity that just wasn't worth it. So I simply aborted the task and moved on to another. Ask your agent to &quot;simplify&quot; the implementation When first implementing the comments, it randomly decided to use one data structure for the Jazz models and then another when displaying the comments to the UI requiring a transform between the two. I simply asked it to &quot;simplify&quot; the implementation removed the extra transformation step. Combine multiple stratgies for the best results At one point the AI agent just failed to get the &quot;resolve&quot; feature for comments correctly so I I stated: &quot;now it's not persisting to Jazz appropriately. Simplify and make work. Use the browser to iterate so that: Resolving a comment immediately triggers the proper UI updates Re-opening a component does the opposite All this saves with Jazz&quot; Sometimes it's helpful for it to just take a step back and try a different approach with clear objectives in mind and the right direction for how to test it's steps. Conclusion Building with AI is a lot of fun and it's a great way to get things done quickly. It's by no means a magic bullet but a project like this would have taken me days if not weeks to build before. Now it only took 3-4 hours (and that's with writing this article too!).","2026-04-30T04:18:57.269Z","019ddc9c-805c-71c8-81be-4ae362cfeb7c","https:\u002F\u002Fblog.vueschool.io\u002Fwp-content\u002Fuploads\u002F2026\u002F04\u002Fcollaborative-editor-example.jpg","2026-04-28T00:00:34.000Z","vibe-coding-a-collaborative-editor-with-comment-support-with-nuxt-ui-and-jazz","This article explores the creation of a collaborative editor using Nuxt UI and Jazz, focusing on real-time editing and comment support. It discusses the final product's features, including a rich text editor with a toolbar, image upload capabilities, and a layout optimized for collaboration. The author also shares insights on leveraging AI in the development process.","Vibe Coding a Collaborative Editor with Comment Support with Nuxt UI and Jazz","2026-04-30T04:19:01.987Z","https:\u002F\u002Fblog.vueschool.io\u002Fvuejs-tutorials\u002Fvibe-coding-a-collaborative-editor-with-comment-support-with-nuxt-ui-and-jazz\u002F?friend=MOKKAPPS","4b0b425ef41c41adbed9d267524c9d8cedcbd7605a41940a149627d2e550ed08",[58,61,64,67,70],{"color":30,"id":59,"name":60,"slug":60},"019d6bd8-fad3-70a9-a74d-ab96e3a2f45d","nuxt",{"color":30,"id":62,"name":63,"slug":63},"019ddc9c-9315-735e-a7e8-8424790e8859","nuxt-ui",{"color":30,"id":65,"name":66,"slug":66},"019ddc9c-931c-743d-a1cb-e4449630fe47","collaboration",{"color":30,"id":68,"name":69,"slug":69},"019ddc9c-9327-744f-a2da-31b64c7b6ba4","editor",{"color":30,"id":71,"name":72,"slug":72},"019d9d20-e07b-7685-9ebb-fae0b963f243","ai",{"content":74,"createdAt":75,"id":76,"image":77,"isAffiliate":20,"isPublished":20,"publishedAt":78,"slug":79,"sourceId":7,"sourceName":8,"sourceType":9,"summary":80,"title":81,"updatedAt":82,"url":83,"urlHash":84,"tags":85},"If you have ever built a chat thread, card feed, whiteboard label editor, or masonry layout in Vue, you have probably ended up doing something slightly gross: render text into the DOM, measure it, and then rerender or reposition everything based on that measurement. That works, but it comes with baggage: hidden measurement nodes getBoundingClientRect() and offsetHeight reads in hot paths resize loops that mix layout and app state virtualization code that needs a height before the row is even mounted Pretext is interesting because it attacks that exact problem. Instead of asking the DOM how tall wrapped text became, it prepares the text once and then lays it out against a width using cached measurements. In other words, you can know a text's height before it is even mounted so that Vue can stay focused on state and rendering while Pretext handles the text math. This is not a replacement for Vue, CSS, or the browser layout engine. It is a way to stop using the DOM as your text calculator when all you really need is line count and height. 👉 Don't quite follow? Check out the demo. It breaks down things visually for you. What Pretext actually does The core model is simple: prepare(text, font) does the expensive work once. layout(prepared, width, lineHeight) returns the wrapped height and line count for that width. That split matters. If the text and font stay the same while the available width changes, you do not need to re-measure every grapheme from scratch on every resize. You reuse the prepared value and run layout again. That makes Pretext especially compelling in UI that has lots of text blocks with widths changing over time: virtualized feeds resizable sidebars draggable canvases with labels shrink-wrapped chat bubbles card grids where item height depends on text The Vue angle Vue is already very good at state transitions. The problem is that text measurement traditionally drags you back into imperative DOM work. You start with clean reactive code: const messages = ref&lt;Message[]&gt;([]); Then layout requirements show up and suddenly you are doing things like: await nextTick(); const height = node.getBoundingClientRect().height; That is the line I would try to delete first. With Pretext, the flow looks more like this: Vue owns the text, width, and rendering state. Pretext derives height and line count from text plus width. Your list, grid, or canvas logic consumes those numbers without mounting probe elements first. That is a much cleaner separation of concerns. Install it ni @chenglou\u002Fpretext A simple Vue composable The main trick is to make prepare() depend on the text and font, but not the width. Width changes should only trigger layout(). import { computed, toValue } from &quot;vue&quot;; import { layout, prepare } from &quot;@chenglou\u002Fpretext&quot;; export function usePretextLayout(options) { const text = computed(() =&gt; toValue(options.text)); const font = computed(() =&gt; toValue(options.font)); const width = computed(() =&gt; toValue(options.width)); const lineHeight = computed(() =&gt; toValue(options.lineHeight)); const prepared = computed(() =&gt; prepare(text.value, font.value)); const result = computed(() =&gt; layout(prepared.value, Math.max(1, width.value), lineHeight.value), ); const height = computed(() =&gt; result.value.height); const lineCount = computed(() =&gt; result.value.lineCount); return { text, font, width, lineHeight, prepared, result, height, lineCount, }; } Why split it this way? Text changes should invalidate the prepared measurement. Font changes should also invalidate it. Width changes should only rerun layout. That maps nicely onto Vue's computed graph. Use it in a component Here is a minimal card example. The width is reactive, but the text measurement does not require mounting a hidden probe element just to discover its height. &lt;script setup lang=&quot;ts&quot;&gt; import { ref } from &quot;vue&quot;; import { usePretextLayout } from &quot;.\u002Fcomposables\u002FusePretextLayout&quot;; const body = ref( &quot;Pretext lets Vue apps estimate wrapped text height without measuring hidden DOM nodes first.&quot;, ); const cardWidth = ref(320); const font = ref(&quot;400 16px Inter, system-ui, sans-serif&quot;); const lineHeight = ref(24); const { height, lineCount } = usePretextLayout({ text: body, font, width: cardWidth, lineHeight, }); &lt;\u002Fscript&gt; &lt;template&gt; &lt;article class=&quot;card&quot; :style=&quot;{ width: `${cardWidth}px` }&quot;&gt; &lt;p&gt;{{ body }}&lt;\u002Fp&gt; &lt;footer&gt;{{ lineCount }} lines, {{ height }}px tall&lt;\u002Ffooter&gt; &lt;\u002Farticle&gt; &lt;\u002Ftemplate&gt; This is the important mindset shift: the card height is no longer something you discover after the browser lays out the paragraph. It is something you can derive from the same reactive inputs that already describe the UI. Where this gets really useful The simplest demo is a single card, but that is not where the real value is. The real value shows up when the old approach creates layout thrash or architectural awkwardness. 1. Virtualized lists with variable-height text Virtualization loves predictable heights. Text-heavy UIs often do not have them. Pretext gives you a better story: prepare message text when data arrives compute row height from the current column width feed that height into your virtualizer rerun layout when the list width changes That is much better than mounting off-screen rows just to measure them. import { layout, prepare, type PreparedText } from &quot;@chenglou\u002Fpretext&quot;; type Row = { id: string; body: string; prepared: PreparedText; }; const font = &quot;400 15px Inter, system-ui, sans-serif&quot;; const lineHeight = 22; const rows: Row[] = apiRows.map((row) =&gt; ({ id: row.id, body: row.body, prepared: prepare(row.body, font), })); function getRowHeight(row: Row, contentWidth: number) { return layout(row.prepared, contentWidth, lineHeight).height + 24; } That pattern fits Vue very naturally. You can prepare once when rows are normalized, then derive heights wherever your layout logic needs them. 2. Chat bubbles that size to content If your message UI wants to make decisions based on line count or wrapped height, Pretext is a cleaner primitive than &quot;render first, inspect later.&quot; Examples: deciding whether a bubble gets compact or roomy chrome estimating whether a message should collapse behind &quot;show more&quot; aligning metadata differently for one-line versus multi-line messages Those are layout decisions based on text shape, not business logic. They should not require DOM probes in every component instance. 3. Canvas, whiteboards, and design tools This is where Pretext starts to feel like a category change rather than a small optimization. Its advanced APIs, including prepareWithSegments(), layoutWithLines(), and layoutNextLine(), are designed for cases where you need more than total height: drawing each wrapped line manually finding the widest produced line routing text line by line through changing widths That is useful for labels on canvases, text around shapes, or any UI where the browser is not directly painting the final text layout for you. A practical caveat: your font string has to match reality Pretext is not guessing in the abstract. It measures text against a font declaration and then lays out against a width and line height. That means two values need to match what your UI actually renders: the font string you pass to prepare() the lineHeight you pass to layout() If your component renders with a different font weight, font family, font size, or line height than the values you gave Pretext, your estimated result will drift from the actual DOM layout. So keep the typography source of truth tight. If the component uses: .message { font: 400 16px Inter, system-ui, sans-serif; line-height: 24px; } then your measurement inputs should match those values. What Pretext does not replace This is the part worth being explicit about, because the interesting thing about Pretext is not that it replaces everything. It replaces one very specific pain point. Pretext does not replace: Vue rendering CSS text styling actual width measurement of your container browser selection, caret behavior, or editing UX the browser's final paint of the real text node You still need a width from somewhere. Sometimes that is a prop. Sometimes it comes from your layout model. Sometimes a ResizeObserver is still appropriate. The difference is that you are no longer using the DOM to answer &quot;how tall did this paragraph become?&quot; That is a much smaller and cleaner dependency on layout. When I would reach for it I would consider Pretext when all of these are true: text height or line count affects layout decisions there are many text blocks, or the calculation happens often hidden measurement DOM is making the code awkward or slow you need the answer before mounting the final row or card I would probably not reach for it when: you are rendering a handful of static paragraphs CSS alone solves the problem you only need the browser to lay out the text once and never revisit it In other words, this is not &quot;replace CSS with a library.&quot; It is &quot;stop abusing the DOM as a calculator in text-heavy reactive UIs.&quot; The broader idea What makes Pretext interesting is not just the API. It is the shift in mental model. For a long time, web developers mostly accepted that wrapped text measurement had to be a DOM problem. Pretext challenges that assumption. In a Vue app, that means some layout decisions that used to live in nextTick(), probe elements, and measurement loops can move back into pure reactive derivation. That is exactly the kind of change I like: not because it is flashy, but because it removes a category of awkward code. Summary Pretext gives Vue developers a better option for text-heavy UI where height and line count matter. You prepare text once, lay it out against width as needed, and stop relying on hidden DOM nodes to tell you what wrapped text looks like.","2026-04-08T06:47:33.045Z","019d6bd8-a30c-720e-b82e-9253cd4d2970","https:\u002F\u002Fblog.vueschool.io\u002Fwp-content\u002Fuploads\u002F2026\u002F04\u002Ffeature-v2-1.jpg","2026-04-03T20:45:27.000Z","using-pretext-in-vue-to-build-variable-height-ui-without-layout-thrash","The article discusses how to use Pretext in Vue to create variable-height UIs without the common pitfalls of layout thrashing. By preparing text measurements in advance, Pretext allows developers to avoid direct DOM manipulations for height calculations, leading to cleaner and more reactive code. This approach is particularly beneficial for UIs with dynamic text and changing widths.","Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash","2026-04-08T06:47:42.561Z","https:\u002F\u002Fblog.vueschool.io\u002Fvuejs-tutorials\u002Fusing-pretext-in-vue-to-build-variable-height-ui-without-layout-thrash\u002F?friend=MOKKAPPS","75c6e75824a6e8739844c6c1bc511bbf6c5bc3302502ab9c9e1c87376385d07c",[86,87,90],{"color":30,"id":31,"name":32,"slug":32},{"color":30,"id":88,"name":89,"slug":89},"019d6bd8-ca26-775c-b9b5-c3439dbe5789","performance",{"color":30,"id":91,"name":92,"slug":92},"019d6bd8-ca8b-709c-b9a5-77d4910da162","ui-components",{"content":94,"createdAt":75,"id":95,"image":96,"isAffiliate":20,"isPublished":20,"publishedAt":97,"slug":98,"sourceId":7,"sourceName":8,"sourceType":9,"summary":99,"title":100,"updatedAt":101,"url":102,"urlHash":103,"tags":104},"If you have ever wired up a &lt;label for=&quot;…&quot;&gt; to an &lt;input id=&quot;…&quot;&gt;, duplicated a component twice, and suddenly had duplicate IDs in the document, you already know why “just pick an id string” does not scale. Vue 3.5 added useId(), a small Composition API helper that generates unique-per-application identifiers that stay consistent between server and client renders. Despite the casual phrase “random ids,” useId() is not a source of cryptographic randomness. It produces deterministic, stable strings that are unique within your Vue app instance. That distinction matters: you get uniqueness and SSR safety without Math.random() or global counters that fight hydration. Why useId() exists Manual patterns break down quickly: Hard-coded IDs collide when the same component is used more than once. Math.random() in setup gives different values on server and client, which can cause hydration mismatches in SSR apps. Hand-rolled incrementing counters are easy to get wrong across async boundaries or shared modules. useId() centralizes ID generation in the framework so labels, aria-* attributes, form controls, and more stay valid and predictable. Use Cases for useId() Common situations where you need a DOM-safe unique string (not cryptographic randomness): Creating unique DOM element IDs for anchor links Associating a label and input in a reusable form field component Anchoring headings for in-page navigation (table of contents) Assigning a unique id to custom tooltip or popover elements Distinguishing multiple error callouts or alerts in a single view Generating ids for ARIA attributes (aria-labelledby, aria-describedby, etc.) Marking tab panels and tab buttons with unique relationships Disambiguating ids in nested reusable components (e.g., accordions, tabs) Generating ids for form controls created at runtime (e.g., survey builders) Basic usage Import useId from vue and call it once per logical id you need in the component. Wire the returned string to id, for, or ARIA attributes as usual. &lt;script setup lang=&quot;ts&quot;&gt; import { useId } from &quot;vue&quot;; const nameFieldId = useId(); &lt;\u002Fscript&gt; &lt;template&gt; &lt;form&gt; &lt;label :for=&quot;nameFieldId&quot;&gt;Name&lt;\u002Flabel&gt; &lt;input :id=&quot;nameFieldId&quot; type=&quot;text&quot; name=&quot;name&quot; autocomplete=&quot;name&quot; \u002F&gt; &lt;\u002Fform&gt; &lt;\u002Ftemplate&gt; Each call to useId() in the same component instance receives a different id. Each instance of the component receives ids distinct from other instances. That matches what you want for accessible, reusable field groups. Multiple ids in one component Need a pair for email and password? Call useId() separately for each control (or group). &lt;script setup lang=&quot;ts&quot;&gt; import { useId } from &quot;vue&quot;; const emailId = useId(); const passwordId = useId(); &lt;\u002Fscript&gt; &lt;template&gt; &lt;div&gt; &lt;label :for=&quot;emailId&quot;&gt;Email&lt;\u002Flabel&gt; &lt;input :id=&quot;emailId&quot; type=&quot;email&quot; autocomplete=&quot;email&quot; \u002F&gt; &lt;\u002Fdiv&gt; &lt;div&gt; &lt;label :for=&quot;passwordId&quot;&gt;Password&lt;\u002Flabel&gt; &lt;input :id=&quot;passwordId&quot; type=&quot;password&quot; autocomplete=&quot;current-password&quot; \u002F&gt; &lt;\u002Fdiv&gt; &lt;\u002Ftemplate&gt; SSR and hydration According to the official API docs, ids from useId() are stable across server and client renders. You can use them in Nuxt, custom SSR setups, or any code path that renders on the server first without worrying that the client will “reroll” different strings during hydration. Multiple Vue apps on one page If more than one Vue application mounts on the same document, you can reduce the chance of clashes between apps by setting an id prefix on each app: import { createApp } from &quot;vue&quot;; import AdminRoot from &quot;.\u002FAdminRoot.vue&quot;; const app = createApp(AdminRoot); app.config.idPrefix = &quot;admin&quot;; app.mount(&quot;#admin-app&quot;); Use a different prefix per app instance so generated ids remain unique in the combined DOM. Important: do not call useId() inside computed() You should avoid invoking useId() inside a computed() getter. As with other composables, calling it there can cause instance conflicts because id registration is tied to component setup order. Instead, create the id at the top level of &lt;script setup&gt; (or setup()) and close over it inside computeds or methods. &lt;script setup lang=&quot;ts&quot;&gt; import { computed, useId } from &quot;vue&quot;; const fieldId = useId(); \u002F\u002F Good: fieldId is fixed for this instance; computed only derives display logic. const describedBy = computed(() =&gt; `${fieldId}-hint`); &lt;\u002Fscript&gt; When you might still use something else useId() is extremely useful on the frontend, but it is not the answer for every problem. Here are some cases where you might be tempted to reach for useId() but should not: Entity primary keys from your API—these should be generated by your database or API server Stable keys in v-for when list identity should follow data (prefer a real id from your database model) Correlation IDs for API requests—they should be unpredictable or traceable by policy; use crypto.randomUUID() or server-issued IDs instead of useId() Security-sensitive tokens—use proper random or server-issued secrets For everyday UI plumbing, though, useId() removes a whole class of duplicate-id and SSR bugs with almost no API surface. Summary To sum up, useId() is a powerful tool for generating unique ids for your application. It's a simple, composable API that helps you avoid duplicate-id and SSR bugs with almost no API surface. Uniqueness - Unique per Vue application; distinct for each component instance and on every call SSR - Produces the same id on server and client—safe for hydration Multiple apps - Use app.config.idPrefix to prevent cross-app id collisions when mounting multiple apps PitfallDo - do not call inside computed(); generate the id in setup scope","019d6bd8-a30c-720e-b82e-94ac62042df7","https:\u002F\u002Fblog.vueschool.io\u002Fwp-content\u002Fuploads\u002F2026\u002F04\u002Ffeature-v2.jpg","2026-04-03T17:04:54.000Z","generating-random-ids-in-vuejs","The article discusses the new useId() helper introduced in Vue 3.5, which generates unique identifiers for DOM elements, ensuring consistency between server and client renders. It highlights the importance of unique IDs for accessibility and SSR safety, providing examples of common use cases and basic implementation in Vue components.","Generating Random IDs in Vue.js","2026-04-08T06:47:42.447Z","https:\u002F\u002Fblog.vueschool.io\u002Fvuejs-tutorials\u002Fgenerating-random-ids-in-vue-js\u002F?friend=MOKKAPPS","9174ab0b7d5dd5024c261a80c2b1895cb25c822f78f6b49a83356425d22df98a",[105,106,109,112],{"color":30,"id":31,"name":32,"slug":32},{"color":30,"id":107,"name":108,"slug":108},"019d6bd8-ca21-71c5-a236-37d94fe57d24","composition-api",{"color":30,"id":110,"name":111,"slug":111},"019d6bd8-ca89-735e-a52a-ee53a80a77a9","ssr",{"color":30,"id":113,"name":114,"slug":114},"019d6bd8-caef-76b9-bacd-363e31e6d2a9","accessibility",{"content":116,"createdAt":117,"id":118,"image":119,"isAffiliate":20,"isPublished":20,"publishedAt":120,"slug":121,"sourceId":7,"sourceName":8,"sourceType":9,"summary":122,"title":123,"updatedAt":124,"url":125,"urlHash":126,"tags":127},"RAG (Retrieval-Augmented Generation) is one of the most practical ways to make AI apps useful in the real world. Instead of asking a model to answer from generic training data, you: Index your own documents. Retrieve the most relevant portions of those documents (called &quot;chunks&quot;) at query time. Generate an answer grounded in those chunks. In this tutorial, you will build a working Nuxt backend that does exactly that using Google's robust but easy-to-implement RAG solution: Gemini File Search. I'll also provide the frontend UI so you can test the flow end to end. What we are building Server side utility functions for interacting with the Gemini File Search API. Server side utility functions for managing the indexing and asking processes. A Nuxt server endpoint that creates a File Search store. A Nuxt server endpoint that uploads and indexes documents (text from a textarea input) into a Gemini File Search store. A polling endpoint that checks the status of the indexing operation. Another endpoint that queries the store with the File Search tool to answer a question. To best showcase the RAG pipeline in a practical way, I'll also provide you with a simple UI to test things out. This includes: An input field to name your File Search store (auto-generated the first time). A text area to provide plain text documents to upload and index into the store. An input field to ask a question about any of the indexed documents Status updates for the indexing and asking processes. A section to display the answer grounded in the indexed text. I'll also provide you with a page to manage the indexed documents in the store (view and delete them). You can download and run the completed demo app from the GitHub repo. Definition of Relevant RAG Terms Throughout this guide, we will use the following terms related to RAG and the Gemini File Search API. Make sure you understand them before continuing. RAG: Retrieval-Augmented Generation is a technique that uses a large language model to answer questions by retrieving relevant context from a knowledge base. File Search: File Search is a Gemini API that allows you to index and search through your own documents. (aka. a batteries-included RAG pipeline) Chunk: A chunk is a portion of a document that is indexed by the File Search API. Documents are split into these chunks so that the model can retrieve only the most relevant context when answering questions. Store: A store is a collection of documents that are indexed by the File Search API. You can query 1 store at a time. Useful for organizing your documents into logical groups. Tool: A tool is a function that can be called by a language model to perform a task. (in this case, the File Search tool) Prerequisites Node.js and npm installed A Google Gemini API key (you can get one from Google AI Studio here) Step 1) Create a Nuxt App Create a new Nuxt app with the minimal template: npm create nuxt@latest nuxt-rag-app -- -t minimal Then work from your app root: cd nuxt-rag-app All paths in the rest of this guide are relative to that project root. Step 2) Install dependencies (Gemini SDK) ni @google\u002Fgenai @nuxtjs\u002Fmdc @google\u002Fgenai is the Google Gemini API SDK. Alternately you could use the AI SDK as we discuss in our course AI Interfaces with Vue, Nuxt, and the AI SDK. It would make streaming the model output to the frontend a piece of cake. Plus it has other benefits but we're going to keep it simple for this tutorial and forego streaming. Step 3) Expose Your API Key via Nuxt Runtime Config Create a .env file in the root of your project and add your API key: NUXT_GOOGLE_GENERATIVE_AI_API_KEY=your-api-key Then update nuxt.config.ts to expose it via runtime config: \u002F\u002F https:\u002F\u002Fnuxt.com\u002Fdocs\u002Fapi\u002Fconfiguration\u002Fnuxt-config export default defineNuxtConfig({ compatibilityDate: &quot;2025-07-15&quot;, devtools: { enabled: true }, modules: [&quot;@nuxtjs\u002Fmdc&quot;], runtimeConfig: { \u002F\u002F this variable name must match the name in the .env file (converted to camelCase and without the NUXT_ prefix) googleGenerativeAiApiKey: &quot;&quot;, }, }); Step 4) Add File Search Helper Functions Before we add API endpoints, let's create a set of helper functions in server\u002Futils\u002Fgemini-file-search.ts to manage the File Search operations with Gemini. 1. Import dependencies First, import the required modules. \u002F\u002F server\u002Futils\u002Fgemini-file-search.ts import { GoogleGenAI } from &quot;@google\u002Fgenai&quot;; 2. Helper function to create an authenticated API client Then create a function that initializes the Gemini API client with your API key and handles missing API key errors. \u002F\u002F server\u002Futils\u002Fgemini-file-search.ts function getApiKey() { const runtimeConfig = useRuntimeConfig(); const apiKey = runtimeConfig.googleGenerativeAiApiKey; if (!apiKey) { throw createError({ statusCode: 500, statusMessage: &quot;Missing NUXT_GOOGLE_GENERATIVE_AI_API_KEY .env variable.&quot;, }); } return apiKey; } function getClient() { return new GoogleGenAI({ apiKey: getApiKey() }); } 3. Create a File Search store Next, provide a helper function for creating a File Search store. \u002F\u002F server\u002Futils\u002Fgemini-file-search.ts export async function createFileSearchStore(params: { displayName: string }) { const ai = getClient(); const store = await ai.fileSearchStores.create({ config: { displayName: params.displayName, }, }); return store.name ?? &quot;&quot;; } 4. Wait for an asynchronous operation from the API to complete Gemini File Search indexing happens asynchronously. This function polls the operation status until it's done. async function waitForOperation(ai: GoogleGenAI, operation: any) { let current = operation; while (!current.done) { await new Promise((resolve) =&gt; setTimeout(resolve, 1000)); current = await ai.operations.get({ operation: current }); } return current; } 5. Upload and index a text document in the store This function uploads your text directly from memory as a Blob, then waits for indexing to finish. export async function uploadTextToStore(params: { fileSearchStoreName: string; content: string; displayName: string; }) { const ai = getClient(); const markdownBlob = new Blob([params.content], { type: &quot;text\u002Fmarkdown&quot; }); const operation = await ai.fileSearchStores.uploadToFileSearchStore({ file: markdownBlob, fileSearchStoreName: params.fileSearchStoreName, config: { displayName: params.displayName, mimeType: &quot;text\u002Fmarkdown&quot;, }, }); await waitForOperation(ai, operation); } 6. Ask questions using the indexed File Search context This function sends a question to Gemini, instructing it to answer only using retrieved context from your uploaded files. If Gemini can't answer with the provided context, it will say so. Most importantly, note the usage of the fileSearch tool that gives Gemini access to the indexed documents! export async function askStore(params: { fileSearchStoreName: string; question: string; }) { const ai = getClient(); const groundedQuestion = [ &quot;Answer using only the retrieved File Search context.&quot;, &#039;If the context does not contain the answer, say: &quot;I do not know based on the uploaded documents.&quot;&#039;, `Question: ${params.question}`, ].join(&quot;\\n&quot;); const response = await ai.models.generateContent({ model: &quot;gemini-3-flash-preview&quot;, contents: groundedQuestion, config: { \u002F\u002F 👇 this is the key part that gives Gemini access to the indexed documents! tools: [ { fileSearch: { fileSearchStoreNames: [params.fileSearchStoreName], }, }, ], }, }); return { text: response.text ?? &quot;&quot;, groundingMetadata: response.candidates?.[0]?.groundingMetadata ?? null, }; } Step 5) Add display-name helper functions After creating the File Search helpers, add one small utility file for generating store and document names. Create server\u002Futils\u002Frag-display-names.ts: \u002F\u002F used if no store name is provided export function createStoreDisplayName() { return `nuxt-rag-store-${Date.now()}`; } \u002F\u002F since we&#039;re using a text input instead of an actual document with a filename, we need to generate a unique name for the document we import \u002F\u002F this does that based on the first non-empty line of the content export function createDisplayNameFromContent(content: string) { const firstNonEmptyLine = content .split(&quot;\\n&quot;) .map((line) =&gt; line.trim()) .find((line) =&gt; line.length &gt; 0); const base = (firstNonEmptyLine || &quot;notes&quot;) .replace(\u002F^#+\\s*\u002F, &quot;&quot;) .replace(\u002F[^a-zA-Z0-9\\s-]\u002Fg, &quot;&quot;) .trim() .replace(\u002F\\s+\u002Fg, &quot;-&quot;) .toLowerCase() .slice(0, 48); return `${base || &quot;notes&quot;}-${Date.now()}`; } Step 6) Add storage and indexing-status helper functions Indexing is asynchronous, so for a better UX we will: create an index job immediately, update job status in KV storage (useStorage()), and poll job status from the frontend. Let's create some helper functions, variables, and types to help manage this process. 1. Define Types and Constants We'll start by defining the job status type, the job object shape, and a key prefix for our storage. \u002F\u002F server\u002Futils\u002Frag-index-jobs.ts import { randomUUID } from &quot;node:crypto&quot;; \u002F\u002F Possible statuses for an indexing job export type RAGIndexJobStatus = &quot;pending&quot; | &quot;succeeded&quot; | &quot;failed&quot;; \u002F\u002F The job object structure export type RAGIndexJob = { id: string; status: RAGIndexJobStatus; fileSearchStoreName: string; displayName: string; createdAt: string; updatedAt: string; errorMessage?: string; \u002F\u002F Populated if failed }; \u002F\u002F Prefix for storing job entries in KV export const INDEX_JOB_PREFIX = &quot;rag:index-job:&quot;; 2. Helpers for Job Storage Keys Next, we need a function to generate a unique storage key for each job based on its ID. export function getJobKey(jobId: string) { return `${INDEX_JOB_PREFIX}${jobId}`; } 3. Creating a New Index Job When we start an indexing operation, we want to create a new job entry in our KV storage with a unique ID and an initial status of &quot;pending&quot;. export async function createIndexJob(params: { fileSearchStoreName: string; displayName: string; }) { const job: RAGIndexJob = { id: randomUUID(), status: &quot;pending&quot;, fileSearchStoreName: params.fileSearchStoreName, displayName: params.displayName, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await useStorage().setItem(getJobKey(job.id), job); return job; } 4. Retrieving a Saved Index Job This function allows you to fetch job details from storage by job ID. export async function getIndexJob(jobId: string) { return await useStorage().getItem&lt;RAGIndexJob&gt;(getJobKey(jobId)); } 5. Marking a Job as Succeeded When an indexing operation completes successfully, use this to update its status. export async function markIndexJobSucceeded(jobId: string) { const existing = await getIndexJob(jobId); if (!existing) return; await useStorage().setItem(getJobKey(jobId), { ...existing, status: &quot;succeeded&quot;, updatedAt: new Date().toISOString(), errorMessage: undefined, }); } 6. Marking a Job as Failed If indexing fails, call this to record the failure and the error message. export async function markIndexJobFailed(params: { jobId: string; errorMessage: string; }) { const existing = await getIndexJob(params.jobId); if (!existing) return; await useStorage().setItem(getJobKey(params.jobId), { ...existing, status: &quot;failed&quot;, updatedAt: new Date().toISOString(), errorMessage: params.errorMessage, }); } With these helpers in place, you can create, update, check, and manage the lifecycle of document indexing jobs in your app. Step 7) Add API endpoints for stores, indexing, and asking questions Now the API layer can focus on request validation and orchestration while reusing helper functions from server\u002Futils. Let's see how to create the API endpoints for creating stores, indexing documents, and asking questions. 1. Create a store API endpoint Create server\u002Fapi\u002Frag\u002Fstore.post.ts with the following code to support creating a store. export default defineEventHandler(async (event) =&gt; { \u002F\u002F get the name of the store from the body const body = await readBody&lt;{ displayName?: string }&gt;(event); const displayName = body.displayName?.trim() || createStoreDisplayName(); \u002F\u002F create the store with the helper function const fileSearchStoreName = await createFileSearchStore({ displayName, }); \u002F\u002F if the store creation failed, throw an error if (!fileSearchStoreName) { throw createError({ statusCode: 500, statusMessage: &quot;Failed to create a File Search store.&quot;, }); } \u002F\u002F return the name of the store return { fileSearchStoreName, }; }); 2. Create an indexing API endpoint Create server\u002Fapi\u002Frag\u002Findex.ts with the following code to support indexing a document. export default defineEventHandler(async (event) =&gt; { const body = await readBody&lt;{ content?: string; displayName?: string; storeName?: string; }&gt;(event); \u002F\u002F get the content of the document to index \u002F\u002F and the store to index it into \u002F\u002F from the request body const content = body.content?.trim(); const fileSearchStoreName = body.storeName?.trim(); \u002F\u002F if the content is not provided, throw an error if (!content) { throw createError({ statusCode: 400, statusMessage: &#039;Request body needs a non-empty &quot;content&quot; field.&#039;, }); } \u002F\u002F if the store name is not provided, throw an error if (!fileSearchStoreName) { throw createError({ statusCode: 400, statusMessage: &#039;Request body needs a non-empty &quot;storeName&quot; field.&#039;, }); } \u002F\u002F if the display name for the document is not provided, create one from the content const displayName = body.displayName || createDisplayNameFromContent(content); \u002F\u002F and then initialize the indexing job in KV storage const job = await createIndexJob({ fileSearchStoreName, displayName, }); \u002F\u002F create a function to bundle \u002F\u002F - doing the indexing \u002F\u002F - and update the indexing status in KV storage const runIndexing = async () =&gt; { try { await uploadTextToStore({ fileSearchStoreName, content, displayName, }); await markIndexJobSucceeded(job.id); } catch (error: any) { await markIndexJobFailed({ jobId: job.id, errorMessage: error?.data?.statusMessage || error?.message || &quot;Unknown indexing error&quot;, }); } }; \u002F\u002F Do the indexing in the background event.waitUntil(runIndexing()); \u002F\u002F and return the job id and status immediately with a 202 status code setResponseStatus(event, 202); return { ok: true, accepted: true, jobId: job.id, jobStatus: &quot;pending&quot;, fileSearchStoreName, }; }); 3. Create an index-status API endpoint With the indexing API endpoint in place, we can kick off the indexing process but we don't yet have a way to check the status of the indexing job. Let's create an endpoint to do that. Create server\u002Fapi\u002Frag\u002Findex-status.get.ts: export default defineEventHandler(async (event) =&gt; { const query = getQuery(event); const jobId = String(query.jobId || &quot;&quot;).trim(); \u002F\u002F require the job id to be provided \u002F\u002F we can&#039;t check the status of a job if we don&#039;t know the job id 🤪 if (!jobId) { throw createError({ statusCode: 400, statusMessage: &#039;Query string needs a non-empty &quot;jobId&quot; value.&#039;, }); } \u002F\u002F get the job from the KV storage const job = await getIndexJob(jobId); \u002F\u002F if the job is not found, throw an error if (!job) { throw createError({ statusCode: 404, statusMessage: &quot;Index job not found.&quot;, }); } \u002F\u002F return the job from the KV storage return { job }; }); Great work. You now have the backend flow for indexing documents. Easier than you thought, right? 4. Create an ask API endpoint What's a document index without a way to ask questions about it? Now let's create an endpoint to do that. Create server\u002Fapi\u002Frag\u002Fask.post.ts: export default defineEventHandler(async (event) =&gt; { \u002F\u002F get the question from the request body \u002F\u002F and the store of documents to ask the question about const body = await readBody&lt;{ question?: string; storeName?: string }&gt;(event); const question = body.question?.trim(); const fileSearchStoreName = body.storeName?.trim(); \u002F\u002F if the question is not provided, throw an error if (!question) { throw createError({ statusCode: 400, statusMessage: &#039;Request body needs a non-empty &quot;question&quot; field.&#039;, }); } \u002F\u002F if the store name is not provided, throw an error if (!fileSearchStoreName) { throw createError({ statusCode: 400, statusMessage: &#039;Request body needs a non-empty &quot;storeName&quot; field.&#039;, }); } \u002F\u002F use the askStore helper function to ask the question of the File Search store \u002F\u002F You could stream this response, but for simplicity we are not doing that here. const result = await askStore({ fileSearchStoreName, question }); \u002F\u002F The groundingChunks from the File Search API return the context used to answer the question. \u002F\u002F we need to map that to the title, text, and fileSearchStore of the document that was used \u002F\u002F so we can display the attributions in the UI const attributions = (result.groundingMetadata?.groundingChunks ?? []) .map((chunk: any) =&gt; chunk?.retrievedContext) .filter(Boolean) .map((ctx: any) =&gt; ({ title: ctx.title ?? &quot;Untitled document&quot;, text: ctx.text ?? &quot;&quot;, fileSearchStore: ctx.fileSearchStore ?? fileSearchStoreName, })); \u002F\u002F that&#039;s it! return { answer: result.text, attributions, groundingMetadata: result.groundingMetadata, fileSearchStoreName, }; }); Step 8) Hook up the UI Since this tutorial focuses on the Nuxt backend and Gemini File Search API, you can find the full frontend code in the GitHub repo. Bonus) Add documents management API endpoints The Gemini File Search API also allows you to list and delete documents from a store. While not strictly necessary for our simple app, it's a good way to showcase the full capabilities of the API. And, of course, you'll likely need to list documents or remove a document from a store at some point in your own apps! Let's add those endpoints to the backend. 1) Add document listing and delete helpers Extend server\u002Futils\u002Fgemini-file-search.ts with: \u002F\u002F server\u002Futils\u002Fgemini-file-search.ts export async function listStoreDocuments(params: { fileSearchStoreName: string; }) { const ai = getClient(); const result: Array&lt;{ name: string; displayName: string }&gt; = []; const documents = await ai.fileSearchStores.documents.list({ parent: params.fileSearchStoreName, }); for await (const document of documents as any) { result.push({ name: document.name ?? &quot;&quot;, displayName: document.displayName ?? document.name ?? &quot;Untitled document&quot;, }); } return result; } export async function deleteStoreDocument(params: { documentName: string }) { const ai = getClient(); await ai.fileSearchStores.documents.delete({ name: params.documentName, config: { force: true }, }); } 2) Add API endpoints Create server\u002Fapi\u002Frag\u002Fdocuments.get.ts: \u002F\u002F server\u002Fapi\u002Frag\u002Fdocuments.get.ts export default defineEventHandler(async (event) =&gt; { const query = getQuery(event); const fileSearchStoreName = String(query.storeName || &quot;&quot;).trim(); if (!fileSearchStoreName) { throw createError({ statusCode: 400, statusMessage: &#039;Query string needs a non-empty &quot;storeName&quot; value.&#039;, }); } const documents = await listStoreDocuments({ fileSearchStoreName }); return { fileSearchStoreName, documents }; }); Create server\u002Fapi\u002Frag\u002Fdocuments.delete.ts: \u002F\u002F server\u002Fapi\u002Frag\u002Fdocuments.delete.ts export default defineEventHandler(async (event) =&gt; { const body = await readBody&lt;{ documentName?: string }&gt;(event); const documentName = body.documentName?.trim(); if (!documentName) throw createError({ statusCode: 400, statusMessage: &quot;Missing documentName&quot;, }); await deleteStoreDocument({ documentName }); return { ok: true }; }); And that's it! You now have a backend API for managing your File Search stores, indexing documents, and asking questions. What we've built: Here are the API endpoints provided in this tutorial: 1. List Documents in a File Search Store Endpoint: GET \u002Fapi\u002Frag\u002Fdocuments?storeName=fileSearchStores\u002Fyour-store-name Returns the list of documents stored in the given File Search store. Example Request: curl &quot;http:\u002F\u002Flocalhost:4310\u002Fapi\u002Frag\u002Fdocuments?storeName=fileSearchStores\u002Fyour-store-name&quot; Example Response: { &quot;fileSearchStoreName&quot;: &quot;fileSearchStores\u002Fyour-store-name&quot;, &quot;documents&quot;: [ { &quot;name&quot;: &quot;fileSearchStores\u002Fyour-store-name\u002Fdocuments\u002Fdoc-1&quot;, &quot;displayName&quot;: &quot;My Document&quot; } ] } 2. Delete a Document from a Store Endpoint: DELETE \u002Fapi\u002Frag\u002Fdocuments Send a JSON body with the document's name to delete it from the store. Example Request: curl -X DELETE &quot;http:\u002F\u002Flocalhost:4310\u002Fapi\u002Frag\u002Fdocuments&quot; \\ -H &quot;content-type: application\u002Fjson&quot; \\ -d &#039;{&quot;documentName&quot;:&quot;fileSearchStores\u002Fyour-store-name\u002Fdocuments\u002Fdoc-1&quot;}&#039; Example Response: { &quot;ok&quot;: true } 3. Create a File Search Store Endpoint: POST \u002Fapi\u002Frag\u002Fstore Creates a new File Search store that will hold your indexed documents. Example Request: curl -s -X POST &quot;http:\u002F\u002Flocalhost:4310\u002Fapi\u002Frag\u002Fstore&quot; \\ -H &quot;content-type: application\u002Fjson&quot; \\ -d &#039;{&quot;displayName&quot;:&quot;smoke-test-store&quot;}&#039; Example Response: { &quot;fileSearchStoreName&quot;: &quot;fileSearchStores\u002Fabc123&quot; } 4. Upload and Index a Document Endpoint: POST \u002Fapi\u002Frag Uploads text content (as plain text) and indexes it into the specified File Search store. Example Request: curl -s -X POST &quot;http:\u002F\u002Flocalhost:4310\u002Fapi\u002Frag&quot; \\ -H &quot;content-type: application\u002Fjson&quot; \\ -d &#039;{&quot;storeName&quot;:&quot;fileSearchStores\u002Fyour-store-name&quot;,&quot;content&quot;:&quot;RAG means Retrieval-Augmented Generation. It retrieves relevant context from your private docs before generation.&quot;,&quot;displayName&quot;:&quot;smoke-test-notes&quot;}&#039; Example Response: { &quot;ok&quot;: true, &quot;accepted&quot;: true, &quot;jobId&quot;: &quot;3f2d41a9-1f2d-43c2-9e21-7f4ce0d3a9b6&quot;, &quot;jobStatus&quot;: &quot;pending&quot;, &quot;fileSearchStoreName&quot;: &quot;fileSearchStores\u002Fyour-store-name&quot; } 5. Poll for Indexing Status Endpoint: GET \u002Fapi\u002Frag\u002Findex-status?jobId=... Checks the status of a long-running indexing operation. Example Request: curl -s &quot;http:\u002F\u002Flocalhost:4310\u002Fapi\u002Frag\u002Findex-status?jobId=3f2d41a9-1f2d-43c2-9e21-7f4ce0d3a9b6&quot; Example Response: { &quot;job&quot;: { &quot;id&quot;: &quot;3f2d41a9-1f2d-43c2-9e21-7f4ce0d3a9b6&quot;, &quot;status&quot;: &quot;succeeded&quot;, &quot;fileSearchStoreName&quot;: &quot;fileSearchStores\u002Fyour-store-name&quot;, &quot;displayName&quot;: &quot;smoke-test-notes&quot; } } 6. Ask Questions Grounded In Your Indexed Docs Endpoint: POST \u002Fapi\u002Frag\u002Fask Sends a natural language question to the backend and gets a grounded answer based on your previously indexed documents. Example Request: curl -s -X POST &quot;http:\u002F\u002Flocalhost:4310\u002Fapi\u002Frag\u002Fask&quot; \\ -H &quot;content-type: application\u002Fjson&quot; \\ -d &#039;{&quot;storeName&quot;:&quot;fileSearchStores\u002Fyour-store-name&quot;,&quot;question&quot;:&quot;What does RAG mean?&quot;}&#039; Example Response: { &quot;answer&quot;: &quot;RAG means Retrieval-Augmented Generation. It retrieves relevant context from your private docs before generation.&quot;, &quot;attributions&quot;: [ { &quot;title&quot;: &quot;smoke-test-notes&quot;, &quot;text&quot;: &quot;RAG means Retrieval-Augmented Generation. It retrieves relevant context from your private docs before generation.&quot;, &quot;fileSearchStore&quot;: &quot;fileSearchStores\u002Fyour-store-name&quot; } ], &quot;groundingMetadata&quot;: { \u002F* ... *\u002F }, &quot;fileSearchStoreName&quot;: &quot;fileSearchStores\u002Fyour-store-name&quot; } Wrapping up If you enjoyed this article you might also be interested in the comprehensive RAG course over on aidd.io. It goes deep into detail about how RAG works and shows you step by step how to build every part of the process from scratch: from gathering documents, to chunking them, to generating embeddings, to indexing them, and more! You'll also probably want to check out the official docs for Gemini File Search.","2026-04-08T06:47:51.245Z","019d6bd8-ea2a-734b-9c29-2aef0720a232","https:\u002F\u002Fblog.vueschool.io\u002Fwp-content\u002Fuploads\u002F2026\u002F03\u002Fdemo-screenshot.jpg","2026-03-25T23:00:54.000Z","rag-with-nuxt-and-gemini-file-search","This tutorial guides you through building a Nuxt backend that implements Retrieval-Augmented Generation (RAG) using Google's Gemini File Search. You'll learn to create server-side utility functions, manage document indexing, and develop a simple UI for querying indexed documents and displaying answers. The article provides a comprehensive overview of the RAG pipeline along with practical implementation steps.","RAG with Nuxt and Gemini File Search","2026-04-08T06:47:55.039Z","https:\u002F\u002Fblog.vueschool.io\u002Fvuejs-tutorials\u002Frag-with-nuxt-and-gemini-file-search\u002F?friend=MOKKAPPS","cd9445a0fbd730d2ddf9b7b249798a6f5ccabf7741514acd63444f85fdfa6076",[128,129,132],{"color":30,"id":59,"name":60,"slug":60},{"color":30,"id":130,"name":131,"slug":131},"019d6bd8-fb46-77ef-a3b6-bd2d30ab8919","api",{"color":30,"id":43,"name":44,"slug":44},1,20,{"tags":136},[137,142,144,147,151,155,158,162,166,170,174,178,183,187,191,195,199,203,207,211,215,219,223,225,229,233,235,239,241,245,249,253,257,261,265,269,273,277,281,285,287,291,295,299,303,307,311,315,319,323,327,331,333,337,341,345,349,353,357,361,365,369,372,374,378,382,386,390,394,397,401,405,409,413,417,421,425,429,433,437,439,443,447,451,455,459,463,467,470,474,478,482,486,490,494,498,502,506,509,513,514,518,522,526,530,534,538,542,545,549,553,558],{"articleCount":138,"color":30,"createdAt":139,"id":140,"name":141,"slug":141,"updatedAt":139},0,"2026-04-30T04:19:02.605Z","019ddc9c-954c-7703-8288-76d562fec577","accelerator",{"articleCount":133,"color":30,"createdAt":143,"id":113,"name":114,"slug":114,"updatedAt":143},"2026-04-08T06:47:43.120Z",{"articleCount":145,"color":30,"createdAt":146,"id":71,"name":72,"slug":72,"updatedAt":146},8,"2026-04-17T20:27:50.780Z",{"articleCount":138,"color":30,"createdAt":148,"id":149,"name":150,"slug":150,"updatedAt":148},"2026-05-05T00:00:19.031Z","019df56f-8257-752e-a918-cdbb07c32b86","ai-agents",{"articleCount":133,"color":30,"createdAt":152,"id":153,"name":154,"slug":154,"updatedAt":152},"2026-06-01T12:00:14.713Z","019e830e-5378-722b-929b-a57d67bcf605","animation",{"articleCount":156,"color":30,"createdAt":157,"id":130,"name":131,"slug":131,"updatedAt":157},4,"2026-04-08T06:47:55.499Z",{"articleCount":145,"color":30,"createdAt":159,"id":160,"name":161,"slug":161,"updatedAt":159},"2026-04-17T19:35:19.300Z","019d9cf0-ca04-75bb-ad86-ce8da1c0be23","architecture",{"articleCount":138,"color":30,"createdAt":163,"id":164,"name":165,"slug":165,"updatedAt":163},"2026-04-23T12:00:11.615Z","019dba36-435e-765d-bfb2-c5be05362c27","astro",{"articleCount":138,"color":30,"createdAt":167,"id":168,"name":169,"slug":169,"updatedAt":167},"2026-06-01T20:00:15.958Z","019e84c5-cc55-7377-9e5d-77240ea8a880","authentication",{"articleCount":138,"color":30,"createdAt":171,"id":172,"name":173,"slug":173,"updatedAt":171},"2026-05-05T00:00:19.020Z","019df56f-824c-7031-9e34-2f47ad139eb6","automation",{"articleCount":138,"color":30,"createdAt":175,"id":176,"name":177,"slug":177,"updatedAt":175},"2026-06-11T16:00:12.019Z","019eb769-9af2-7471-b482-3f1a404f802e","azure",{"articleCount":179,"color":30,"createdAt":180,"id":181,"name":182,"slug":182,"updatedAt":180},2,"2026-04-17T20:27:50.776Z","019d9d20-e077-71cc-a605-8ac2a1566143","backend",{"articleCount":156,"color":30,"createdAt":184,"id":185,"name":186,"slug":186,"updatedAt":184},"2026-04-08T06:47:56.976Z","019d6bd9-010e-772d-b2f0-9378a042a676","best-practices",{"articleCount":138,"color":30,"createdAt":188,"id":189,"name":190,"slug":190,"updatedAt":188},"2026-06-06T00:00:11.711Z","019e9a3a-e5be-74b3-a01a-92c1f9427a2c","beta",{"articleCount":138,"color":30,"createdAt":192,"id":193,"name":194,"slug":194,"updatedAt":192},"2026-06-10T13:53:29.711Z","019eb1cf-3e6e-70f0-a761-0fb9b61d5675","budgeting",{"articleCount":133,"color":30,"createdAt":196,"id":197,"name":198,"slug":198,"updatedAt":196},"2026-06-16T16:11:31.438Z","019ed133-c4ee-70bb-8e21-7dc86e8adee4","bun",{"articleCount":138,"color":30,"createdAt":200,"id":201,"name":202,"slug":202,"updatedAt":200},"2026-05-19T20:00:14.559Z","019e41d3-1ade-77b0-9e4f-e9b97a6361ca","cdn",{"articleCount":133,"color":30,"createdAt":204,"id":205,"name":206,"slug":206,"updatedAt":204},"2026-06-27T16:00:12.237Z","019f09cf-5bcd-751c-8d46-9e123bd784af","clean-code",{"articleCount":133,"color":30,"createdAt":208,"id":209,"name":210,"slug":210,"updatedAt":208},"2026-05-16T18:48:40.777Z","019e321e-8248-75cc-9b69-59c2db6dd846","cli",{"articleCount":138,"color":30,"createdAt":212,"id":213,"name":214,"slug":214,"updatedAt":212},"2026-05-05T00:00:19.014Z","019df56f-8246-747f-be4b-a716863a93ea","cloud-platform",{"articleCount":133,"color":30,"createdAt":216,"id":217,"name":218,"slug":218,"updatedAt":216},"2026-06-16T16:11:31.289Z","019ed133-c458-74d8-a5c9-654f77837e7c","cloudflare",{"articleCount":133,"color":30,"createdAt":220,"id":221,"name":222,"slug":222,"updatedAt":220},"2026-07-27T18:11:45.304Z","019fa4c6-93c3-76ea-baa3-7d5fe541ac5b","cms",{"articleCount":133,"color":30,"createdAt":224,"id":65,"name":66,"slug":66,"updatedAt":224},"2026-04-30T04:19:02.045Z",{"articleCount":133,"color":30,"createdAt":226,"id":227,"name":228,"slug":228,"updatedAt":226},"2026-06-10T13:53:29.567Z","019eb1cf-3dde-776d-9398-4863764f9ac3","community",{"articleCount":133,"color":30,"createdAt":230,"id":231,"name":232,"slug":232,"updatedAt":230},"2026-05-06T16:00:17.128Z","019dfe04-bee8-7384-bc58-a712f677807c","comparison",{"articleCount":133,"color":30,"createdAt":234,"id":34,"name":35,"slug":35,"updatedAt":234},"2026-05-14T12:00:21.354Z",{"articleCount":133,"color":30,"createdAt":236,"id":237,"name":238,"slug":238,"updatedAt":236},"2026-07-18T20:00:18.730Z","019f76d0-bb29-72d6-8d0b-05ffd1d3c8ed","composables",{"articleCount":156,"color":30,"createdAt":240,"id":107,"name":108,"slug":108,"updatedAt":240},"2026-04-08T06:47:42.915Z",{"articleCount":133,"color":30,"createdAt":242,"id":243,"name":244,"slug":244,"updatedAt":242},"2026-06-27T12:00:13.113Z","019f08f3-a538-74ed-82c5-038edce7100a","copilot",{"articleCount":138,"color":30,"createdAt":246,"id":247,"name":248,"slug":248,"updatedAt":246},"2026-04-25T12:00:12.674Z","019dc482-ff81-73ac-9b1c-6ad47f0afde0","data-management",{"articleCount":138,"color":30,"createdAt":250,"id":251,"name":252,"slug":252,"updatedAt":250},"2026-06-04T20:00:17.367Z","019e9438-e5d7-731f-bf47-8dcd86c6655c","data-privacy",{"articleCount":133,"color":30,"createdAt":254,"id":255,"name":256,"slug":256,"updatedAt":254},"2026-05-05T12:00:17.083Z","019df802-a8bb-775a-b843-93d883c7dfc5","data-science",{"articleCount":138,"color":30,"createdAt":258,"id":259,"name":260,"slug":260,"updatedAt":258},"2026-06-11T16:00:12.028Z","019eb769-9afb-7709-8a67-2a12f5e557c7","deepseek",{"articleCount":133,"color":30,"createdAt":262,"id":263,"name":264,"slug":264,"updatedAt":262},"2026-05-25T08:00:12.834Z","019e5e26-0e21-7366-87c7-0b1334180b0a","dependency-cruiser",{"articleCount":133,"color":30,"createdAt":266,"id":267,"name":268,"slug":268,"updatedAt":266},"2026-04-18T04:30:00.710Z","019d9eda-5005-7329-a463-189572e25635","deployment",{"articleCount":179,"color":30,"createdAt":270,"id":271,"name":272,"slug":272,"updatedAt":270},"2026-04-21T12:00:11.373Z","019dafe9-8a6d-7218-81f1-37052cbe9b78","development",{"articleCount":133,"color":30,"createdAt":274,"id":275,"name":276,"slug":276,"updatedAt":274},"2026-06-29T08:00:12.101Z","019f1264-9f44-762d-b5fd-837839fdc586","devtools",{"articleCount":138,"color":30,"createdAt":278,"id":279,"name":280,"slug":280,"updatedAt":278},"2026-05-29T20:00:13.197Z","019e7552-ad8c-731f-ad8b-49253ed0e1b9","docker",{"articleCount":133,"color":30,"createdAt":282,"id":283,"name":284,"slug":284,"updatedAt":282},"2026-06-27T12:00:13.081Z","019f08f3-a518-7607-aa8c-782b4f10c2ed","documentation",{"articleCount":133,"color":30,"createdAt":286,"id":68,"name":69,"slug":69,"updatedAt":286},"2026-04-30T04:19:02.055Z",{"articleCount":133,"color":30,"createdAt":288,"id":289,"name":290,"slug":290,"updatedAt":288},"2026-07-06T12:00:24.261Z","019f374d-0cc4-71ff-b906-ebaec8b0c343","eslint",{"articleCount":138,"color":30,"createdAt":292,"id":293,"name":294,"slug":294,"updatedAt":292},"2026-05-02T00:00:23.123Z","019de5fc-7e52-740a-807c-d322c373865b","firewall",{"articleCount":133,"color":30,"createdAt":296,"id":297,"name":298,"slug":298,"updatedAt":296},"2026-05-06T16:00:17.134Z","019dfe04-beee-7481-b8e7-4034640e840a","frameworks",{"articleCount":138,"color":30,"createdAt":300,"id":301,"name":302,"slug":302,"updatedAt":300},"2026-04-08T06:47:56.883Z","019d6bd9-00b2-7199-8e88-2530ef258032","generics",{"articleCount":133,"color":30,"createdAt":304,"id":305,"name":306,"slug":306,"updatedAt":304},"2026-07-27T18:11:45.390Z","019fa4c6-9419-7657-844e-58ec868ed664","git",{"articleCount":179,"color":30,"createdAt":308,"id":309,"name":310,"slug":310,"updatedAt":308},"2026-07-15T00:00:22.207Z","019f6313-12bf-7151-81f2-c8b43b09d979","html",{"articleCount":138,"color":30,"createdAt":312,"id":313,"name":314,"slug":314,"updatedAt":312},"2026-05-06T00:00:17.012Z","019dfa95-d673-71ef-be4a-8761512ffe5c","infrastructure",{"articleCount":133,"color":30,"createdAt":316,"id":317,"name":318,"slug":318,"updatedAt":316},"2026-06-16T16:11:31.264Z","019ed133-c43f-704f-8c1a-fbc299c8a3e5","javascript",{"articleCount":133,"color":30,"createdAt":320,"id":321,"name":322,"slug":322,"updatedAt":320},"2026-07-13T08:00:18.266Z","019f5a7d-bf5a-76e0-903a-c87435cc3e09","lazy-loading",{"articleCount":133,"color":30,"createdAt":324,"id":325,"name":326,"slug":326,"updatedAt":324},"2026-05-11T12:00:19.963Z","019e16e8-dbfa-76d6-bb2d-793aee049186","loading",{"articleCount":138,"color":30,"createdAt":328,"id":329,"name":330,"slug":330,"updatedAt":328},"2026-04-25T12:00:12.682Z","019dc482-ff8a-7698-8af5-95db54a26d6b","local-first",{"articleCount":133,"color":30,"createdAt":332,"id":40,"name":41,"slug":41,"updatedAt":332},"2026-05-14T12:00:21.370Z",{"articleCount":133,"color":30,"createdAt":334,"id":335,"name":336,"slug":336,"updatedAt":334},"2026-05-10T16:00:18.613Z","019e129e-34b4-7107-acfb-27f6b8604eb0","management",{"articleCount":133,"color":30,"createdAt":338,"id":339,"name":340,"slug":340,"updatedAt":338},"2026-06-27T12:00:13.065Z","019f08f3-a508-7334-aa03-12b281698ea8","markdown",{"articleCount":133,"color":30,"createdAt":342,"id":343,"name":344,"slug":344,"updatedAt":342},"2026-07-28T12:00:27.878Z","019fa899-02e6-758e-bdcb-8a4a923507df","mcp",{"articleCount":133,"color":30,"createdAt":346,"id":347,"name":348,"slug":348,"updatedAt":346},"2026-06-17T12:00:12.439Z","019ed574-0a96-7485-9c90-522e4be3e092","meta-tags",{"articleCount":138,"color":30,"createdAt":350,"id":351,"name":352,"slug":352,"updatedAt":350},"2026-05-26T08:00:14.598Z","019e634c-7105-7073-bc86-c32fa0a4401b","microfrontends",{"articleCount":138,"color":30,"createdAt":354,"id":355,"name":356,"slug":356,"updatedAt":354},"2026-05-19T16:00:13.516Z","019e40f7-5ccc-729c-92ba-bcc0aad8a16f","microvm",{"articleCount":138,"color":30,"createdAt":358,"id":359,"name":360,"slug":360,"updatedAt":358},"2026-05-05T00:00:19.025Z","019df56f-8250-768c-a659-b9f524ff902c","multi-tenant",{"articleCount":138,"color":30,"createdAt":362,"id":363,"name":364,"slug":364,"updatedAt":362},"2026-05-08T04:00:17.998Z","019e05be-4c4d-759e-a51e-8ac760f82f6d","nextjs",{"articleCount":133,"color":30,"createdAt":366,"id":367,"name":368,"slug":368,"updatedAt":366},"2026-04-17T19:35:19.293Z","019d9cf0-c9fc-76a0-8c4e-b818a89c3774","nitro",{"articleCount":370,"color":30,"createdAt":371,"id":59,"name":60,"slug":60,"updatedAt":371},27,"2026-04-08T06:47:55.381Z",{"articleCount":133,"color":30,"createdAt":373,"id":62,"name":63,"slug":63,"updatedAt":373},"2026-04-30T04:19:02.038Z",{"articleCount":133,"color":30,"createdAt":375,"id":376,"name":377,"slug":377,"updatedAt":375},"2026-06-08T12:00:16.030Z","019ea71a-dc9d-7607-9cfa-df0f31ab5876","observability",{"articleCount":138,"color":30,"createdAt":379,"id":380,"name":381,"slug":381,"updatedAt":379},"2026-05-04T20:00:20.503Z","019df493-ce17-76ed-bd80-2a3914e0f409","open-source",{"articleCount":133,"color":30,"createdAt":383,"id":384,"name":385,"slug":385,"updatedAt":383},"2026-06-08T12:00:16.022Z","019ea71a-dc95-72b8-a7e3-70f9e2ac3710","opentelemetry",{"articleCount":156,"color":30,"createdAt":387,"id":388,"name":389,"slug":389,"updatedAt":387},"2026-05-18T12:00:14.389Z","019e3af5-4a35-705c-8ab1-4404b653e0e8","optimization",{"articleCount":133,"color":30,"createdAt":391,"id":392,"name":393,"slug":393,"updatedAt":391},"2026-05-28T20:00:13.016Z","019e702c-50d7-74eb-8849-8b1cebcf411e","orchestration",{"articleCount":395,"color":30,"createdAt":396,"id":88,"name":89,"slug":89,"updatedAt":396},16,"2026-04-08T06:47:42.920Z",{"articleCount":133,"color":30,"createdAt":398,"id":399,"name":400,"slug":400,"updatedAt":398},"2026-06-10T13:53:29.575Z","019eb1cf-3de7-7326-87d9-c55ad1c0fa39","personalization",{"articleCount":133,"color":30,"createdAt":402,"id":403,"name":404,"slug":404,"updatedAt":402},"2026-04-17T19:35:19.263Z","019d9cf0-c9de-70b2-9057-76d771c3379e","pinia",{"articleCount":138,"color":30,"createdAt":406,"id":407,"name":408,"slug":408,"updatedAt":406},"2026-05-02T00:00:23.112Z","019de5fc-7e47-7075-8aeb-9e90f7689f57","postgres",{"articleCount":138,"color":30,"createdAt":410,"id":411,"name":412,"slug":412,"updatedAt":410},"2026-05-19T20:00:14.575Z","019e41d3-1aee-70c8-a07c-cec870115d14","pricing",{"articleCount":133,"color":30,"createdAt":414,"id":415,"name":416,"slug":416,"updatedAt":414},"2026-05-10T16:00:18.603Z","019e129e-34aa-723b-a568-45737915c7bf","qa",{"articleCount":133,"color":30,"createdAt":418,"id":419,"name":420,"slug":420,"updatedAt":418},"2026-05-08T04:00:18.013Z","019e05be-4c5c-75ad-8df5-602b3db57983","react",{"articleCount":156,"color":30,"createdAt":422,"id":423,"name":424,"slug":424,"updatedAt":422},"2026-04-20T12:00:11.653Z","019daac3-2f85-7393-b891-9da3891267ca","reactivity",{"articleCount":156,"color":30,"createdAt":426,"id":427,"name":428,"slug":428,"updatedAt":426},"2026-04-17T20:27:50.585Z","019d9d20-dfb9-7041-9973-39c545b33a2d","release",{"articleCount":138,"color":30,"createdAt":430,"id":431,"name":432,"slug":432,"updatedAt":430},"2026-05-26T08:00:14.619Z","019e634c-711a-728f-9b63-20f2c8b37ccb","routing",{"articleCount":138,"color":30,"createdAt":434,"id":435,"name":436,"slug":436,"updatedAt":434},"2026-05-19T16:00:13.509Z","019e40f7-5cc4-72e1-8f8b-692dd29fc716","sandbox",{"articleCount":133,"color":30,"createdAt":438,"id":37,"name":38,"slug":38,"updatedAt":438},"2026-05-14T12:00:21.362Z",{"articleCount":138,"color":30,"createdAt":440,"id":441,"name":442,"slug":442,"updatedAt":440},"2026-05-04T20:00:20.521Z","019df493-ce28-75e9-93d5-13251407a27b","scanning",{"articleCount":156,"color":30,"createdAt":444,"id":445,"name":446,"slug":446,"updatedAt":444},"2026-04-27T08:00:12.420Z","019dcdf3-fc84-73aa-a0be-6b7196c5a2e9","security",{"articleCount":133,"color":30,"createdAt":448,"id":449,"name":450,"slug":450,"updatedAt":448},"2026-06-17T12:00:12.428Z","019ed574-0a8b-7566-976f-8c281c820ed0","seo",{"articleCount":133,"color":30,"createdAt":452,"id":453,"name":454,"slug":454,"updatedAt":452},"2026-06-17T12:00:12.433Z","019ed574-0a91-74d8-b806-56681bb4b477","sitemap",{"articleCount":133,"color":30,"createdAt":456,"id":457,"name":458,"slug":458,"updatedAt":456},"2026-07-18T16:00:27.576Z","019f75f5-23b8-72eb-a2bf-79dd1fed3863","software-development",{"articleCount":133,"color":30,"createdAt":460,"id":461,"name":462,"slug":462,"updatedAt":460},"2026-04-17T19:35:19.297Z","019d9cf0-ca00-749d-9101-1c63bf62e215","spas",{"articleCount":179,"color":30,"createdAt":464,"id":465,"name":466,"slug":466,"updatedAt":464},"2026-06-03T16:00:12.620Z","019e8e36-bd4b-740d-b23a-81858b24025b","ssg",{"articleCount":468,"color":30,"createdAt":469,"id":110,"name":111,"slug":111,"updatedAt":469},9,"2026-04-08T06:47:43.020Z",{"articleCount":138,"color":30,"createdAt":471,"id":472,"name":473,"slug":473,"updatedAt":471},"2026-04-30T04:19:02.613Z","019ddc9c-9555-7544-a3e3-0605d2c1ea82","startup",{"articleCount":156,"color":30,"createdAt":475,"id":476,"name":477,"slug":477,"updatedAt":475},"2026-04-18T12:00:12.027Z","019da076-78fb-706b-9a4c-cee0c989cfff","state-management",{"articleCount":138,"color":30,"createdAt":479,"id":480,"name":481,"slug":481,"updatedAt":479},"2026-06-06T00:00:11.717Z","019e9a3a-e5c5-76d3-86e4-576c151a87b3","storage",{"articleCount":133,"color":30,"createdAt":483,"id":484,"name":485,"slug":485,"updatedAt":483},"2026-06-17T12:00:12.446Z","019ed574-0a9e-7712-8bc5-a0102787fe48","structured-data",{"articleCount":133,"color":30,"createdAt":487,"id":488,"name":489,"slug":489,"updatedAt":487},"2026-05-10T16:00:18.597Z","019e129e-34a4-771a-844d-09a7edefcd64","tdd",{"articleCount":138,"color":30,"createdAt":491,"id":492,"name":493,"slug":493,"updatedAt":491},"2026-06-04T20:00:17.351Z","019e9438-e5c6-7681-8704-897c7b499eb4","terms-of-service",{"articleCount":179,"color":30,"createdAt":495,"id":496,"name":497,"slug":497,"updatedAt":495},"2026-04-09T06:11:45.799Z","019d70de-3c07-76bf-9688-9619e1b0d427","testing",{"articleCount":133,"color":30,"createdAt":499,"id":500,"name":501,"slug":501,"updatedAt":499},"2026-06-16T16:11:31.088Z","019ed133-c390-75bf-8f1a-5c57cd221f14","threejs",{"articleCount":138,"color":30,"createdAt":503,"id":504,"name":505,"slug":505,"updatedAt":503},"2026-05-02T00:00:23.130Z","019de5fc-7e59-7426-8d46-e79e4bcf0d56","tls",{"articleCount":507,"color":30,"createdAt":508,"id":43,"name":44,"slug":44,"updatedAt":508},10,"2026-04-08T06:47:55.591Z",{"articleCount":156,"color":30,"createdAt":510,"id":511,"name":512,"slug":512,"updatedAt":510},"2026-04-08T06:47:56.778Z","019d6bd9-0049-72ba-8cfa-139b1c1b249d","typescript",{"articleCount":5,"color":30,"createdAt":469,"id":91,"name":92,"slug":92,"updatedAt":469},{"articleCount":133,"color":30,"createdAt":515,"id":516,"name":517,"slug":517,"updatedAt":515},"2026-05-11T12:00:19.969Z","019e16e8-dc00-714d-911a-a5bf39238587","user-experience",{"articleCount":133,"color":30,"createdAt":519,"id":520,"name":521,"slug":521,"updatedAt":519},"2026-05-18T12:00:14.379Z","019e3af5-4a29-759a-81b0-b6d502b2449e","v-memo",{"articleCount":138,"color":30,"createdAt":523,"id":524,"name":525,"slug":525,"updatedAt":523},"2026-04-30T04:19:02.228Z","019ddc9c-93d3-763e-ad3c-d3ad78642de7","vercel",{"articleCount":133,"color":30,"createdAt":527,"id":528,"name":529,"slug":529,"updatedAt":527},"2026-07-13T08:00:18.274Z","019f5a7d-bf61-746b-a44b-5c0a16ff57d3","video",{"articleCount":156,"color":30,"createdAt":531,"id":532,"name":533,"slug":533,"updatedAt":531},"2026-04-17T19:35:19.289Z","019d9cf0-c9f8-7411-beed-02265d8271db","vite",{"articleCount":133,"color":30,"createdAt":535,"id":536,"name":537,"slug":537,"updatedAt":535},"2026-04-25T18:08:02.132Z","019dc5d3-c054-70e2-bca4-08e2a2b9a096","vitest",{"articleCount":133,"color":30,"createdAt":539,"id":540,"name":541,"slug":541,"updatedAt":539},"2026-06-27T12:00:13.107Z","019f08f3-a532-7049-a02c-c17a4fd99306","vscode",{"articleCount":543,"color":30,"createdAt":544,"id":31,"name":32,"slug":32,"updatedAt":544},32,"2026-04-08T06:47:42.793Z",{"articleCount":133,"color":30,"createdAt":546,"id":547,"name":548,"slug":548,"updatedAt":546},"2026-04-18T12:00:12.007Z","019da076-78e6-76bd-b0d4-4e0597d36378","vue-router",{"articleCount":138,"color":30,"createdAt":550,"id":551,"name":552,"slug":552,"updatedAt":550},"2026-05-04T20:00:20.510Z","019df493-ce1d-7039-811a-ed57bf081d26","vulnerability",{"articleCount":554,"color":30,"createdAt":555,"id":556,"name":557,"slug":557,"updatedAt":555},3,"2026-05-05T12:00:17.078Z","019df802-a8b6-74d2-a0cd-b0509cf502fa","web-development",{"articleCount":554,"color":30,"createdAt":559,"id":560,"name":561,"slug":561,"updatedAt":559},"2026-05-10T16:00:18.576Z","019e129e-3490-7739-a218-5454a8c15d6e","workflow"]