Vuetify & vue-postgrest todos
Perfect—here’s a Vue + Vuetify + vue-postgrest CRUD example for a todos table. It shows list, add, edit, and delete using v-data-table and dialogs. It uses vue-postgrest’s pg mixin, pg.$new() for creates, and per-row item.$patch() / item.$delete() for updates/deletes (that’s how the library is designed).
Project setup
Vue 3 + Vite + Vuetify
npm create vite@latest vue-pg-todos -- --template vue cd vue-pg-todos npm i vuetify @mdi/font vue-postgrest
main.ts
<br />// src/main.ts import { createApp } from 'vue' import App from './App.vue' // Vuetify import 'vuetify/styles' import { createVuetify } from 'vuetify' import { aliases, mdi } from 'vuetify/iconsets/mdi' import '@mdi/font/css/materialdesignicons.css' const vuetify = createVuetify({ icons: { defaultSet: 'mdi', aliases, sets: { mdi } }, }) // vue-postgrest import VuePostgrest from 'vue-postgrest' // Create app const app = createApp(App) // Point to your PostgREST base URL app.use(VuePostgrest, { apiRoot: 'http://localhost:3000/', // e.g. http://localhost:3000/ // headers: { Authorization: `Bearer ${yourJWT}` } // if you use JWT }) app.use(vuetify).mount('#app') App.vue <!-- src/App.vue --> <template> <v-app> <v-main> <v-container class="py-8"> <TodosCrud /> </v-container> </v-main> </v-app> </template> <script setup lang="ts"> import TodosCrud from './components/TodosCrud.vue' </script>
CRUD component
<!-- src/components/TodosCrud.vue --> <template> <v-card> <v-card-title class="d-flex justify-space-between"> <span class="text-h6">Todos</span> <div class="d-flex ga-2"> <v-text-field v-model="search" density="compact" hide-details label="Search title" prepend-inner-icon="mdi-magnify" style="max-width: 280px" @keyup.enter="refresh" /> <v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate"> Add </v-btn> </div> </v-card-title> <v-data-table :items="todos" :headers="headers" :search="search" :loading="pg.$get.isPending" item-key="id" class="elevation-1" > <template #item.done="{ item }"> <v-checkbox-btn v-model="item.done" @change="saveInline(item)" /> </template> <template #item.actions="{ item }"> <v-btn size="small" icon="mdi-pencil" variant="text" @click="openEdit(item)" /> <v-btn size="small" color="error" icon="mdi-delete" variant="text" @click="confirmDelete(item)" /> </template> <template #no-data> <v-alert type="info" variant="tonal">No todos yet.</v-alert> </template> </v-data-table> </v-card> <!-- Create/Edit dialog --> <v-dialog v-model="dialog.open" max-width="520px"> <v-card :title="dialog.mode === 'create' ? 'Add Todo' : 'Edit Todo'"> <v-card-text> <v-form ref="formRef" @submit.prevent="submitDialog"> <v-text-field v-model="dialog.model.title" label="Title" :rules="[v => !!v || 'Required']" required /> <v-switch v-model="dialog.model.done" label="Done?" /> </v-form> <v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-4" :text="errorMessage" /> </v-card-text> <v-card-actions> <v-spacer /> <v-btn variant="text" @click="closeDialog">Cancel</v-btn> <v-btn :loading="saving" color="primary" @click="submitDialog"> Save </v-btn> </v-card-actions> </v-card> </v-dialog> <!-- Delete confirm --> <v-dialog v-model="confirm.open" max-width="440px"> <v-card title="Delete todo?"> <v-card-text> This action cannot be undone. </v-card-text> <v-card-actions> <v-spacer /> <v-btn variant="text" @click="confirm.open = false">Cancel</v-btn> <v-btn color="error" :loading="saving" @click="doDelete"> Delete </v-btn> </v-card-actions> </v-card> </v-dialog> </template> <script lang="ts"> import { defineComponent, ref, computed } from 'vue' import { pg } from 'vue-postgrest' // mixin that wires PostgREST queries & models. 1 export default defineComponent({ name: 'TodosCrud', mixins: [pg], data() { return { // vue-postgrest mixin config: point to the "todos" route pgConfig: { route: 'todos', query: { select: ['id', 'title', 'done', 'created_at'], order: { id: 'asc' }, // server-side ordering. 2 }, count: 'exact', // Prefer: count=exact, enables pg.$range.totalCount. 3 }, search: '', headers: [ { title: 'ID', key: 'id', sortable: true, width: 80 }, { title: 'Title', key: 'title', sortable: true }, { title: 'Done', key: 'done', sortable: true, width: 110 }, { title: 'Created', key: 'created_at', sortable: true, width: 200 }, { title: 'Actions', key: 'actions', sortable: false, width: 120 }, ], dialog: { open: false as boolean, mode: 'create' as 'create' | 'edit', model: null as any, // will hold a vue-postgrest GenericModel. 4 }, confirm: { open: false as boolean, item: null as any, }, saving: false, errorMessage: '', } }, computed: { // this.pg is a GenericCollection of GenericModels that wrap items. 5 todos(): any[] { return (this as any).pg }, }, methods: { refresh() { (this as any).pg.$get() // re-fetch list. exposes pending/error flags. 6 }, openCreate() { // Create a new client-side model with default values. 7 this.dialog.model = (this as any).pg.$new({ title: '', done: false, }) this.dialog.mode = 'create' this.errorMessage = '' this.dialog.open = true }, openEdit(item: any) { // Clone the model so cancel won't mutate the table immediately this.dialog.model = (this as any).pg.$new({ id: item.id, title: item.title, done: item.done, }) this.dialog.mode = 'edit' this.errorMessage = '' this.dialog.open = true }, async submitDialog() { this.saving = true this.errorMessage = '' try { if (this.dialog.mode === 'create') { // POST create; return=minimal keeps model lightweight. 8 await this.dialog.model.$post({ return: 'minimal' }) } else { // PATCH update only changed columns when possible. 9 await this.dialog.model.$patch() } this.closeDialog() this.refresh() } catch (e: any) { this.errorMessage = e?.message || 'Save failed' } finally { this.saving = false } }, closeDialog() { this.dialog.open = false this.dialog.model = null }, async saveInline(item: any) { // Toggle "done" via inline checkbox → PATCH the row. 10 try { await item.$patch({ columns: ['done'] }) } catch (e) { // revert UI if it fails item.done = !item.done } }, confirmDelete(item: any) { this.confirm.item = item this.confirm.open = true }, async doDelete() { if (!this.confirm.item) return this.saving = true try { await this.confirm.item.$delete() // DELETE the row. 11 this.confirm.open = false this.refresh() } catch (e) { // surface a toast/snackbar in a real app } finally { this.saving = false } }, }, }) </script>
PostgREST side (example table)
-- example schema CREATE TABLE public.todos( id serial PRIMARY KEY, title text NOT NULL, done boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now() ); GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO anon; -- or your web role
Why this works
vue-postgrest exposes a pg mixin that fetches a route (table/view) and returns a GenericCollection of GenericModels in this.pg. You can mutate model fields with v-model and persist via model.$patch(); create via pg.$new(...).$post(); delete via model.$delete().
query.order / query.select and other keys map directly to PostgREST query syntax (ordering, filtering).
count: 'exact' enables total counts via Content-Range, useful if you later add server-side pagination UI.
If you want server-side pagination with v-data-table (using headers for sort → passing to pgConfig.query.order, and using limit/offset with pg.$range.totalCount), I can wire that up too.