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.