An opinionated fullstack template to hit the ground running with Convex + Nuxt, configured with Tailwind, Shadcn and Clerk auth, as well as ready-to-go GitHub actions.
- Convex backend configured out of the box
- Auth using Clerk, webhooks already configured with signature verification
- Basic user/organization models and relations
- Data migrations built-in with an opinionated structure
bun run seedsto insert some basic seeds (and to allow new seeds to be added seamlessly)
- Nuxt configured with Tailwind and Shadcn, working properly with the Convex backend
bun run components:add <component>to add a new Shadcn component- Auth already setup with Clerk
- An opinionated
domainsfolder to store UI code separated by domain
- GitHub Actions
- ESLint check for PRs and merges to
main - TS build check for PRs and merges to
main
- ESLint check for PRs and merges to
First thing you need to do, is to configure a Clerk application. You can do that by following steps 1 to 3 from the Convex guide and saving the Frontend API URL (also presented as Issuer). We will use that in a bit.
You should also configure a webhook for the user.created, user.deleted and user.updated events. After the webhook has been configured in Clerk, get the Signing Secret and save it. We will also use it in a bit.
Finally, go get the Publishable API Key from Clerk, and also save it. We will (yet again) use it in a bit.
Make sure to intall the deps:
bun installStart the Convex backend by running the following command:
bun run convexAfter initializing the new Convex application, once the backend is running, go to the Convex dashboard (http://localhost:6790 if you initialized a local instance, https://dashboard.convex.dev/t/{username}/{app-name}/{some-random-string} if you initialized a cloud instance), navigate to Settings -> Environment Variables and set the following variables:
CLERK_FRONTEND_API_URL: The Frontend API URL (also presented as Issuer) we just saved a couple of steps back, when configuring ClerkCLERK_WEBHOOKS_SECRET: The Signing Secret we just saved a couple of steps back, when configuring Clerk. You can skip this one if you are not testing webhooks yetFRONTEND_ORIGIN(optional): The URL of the frontend. This defaults tohttp://localhost:3000, so you should only need to set it in production or if your frontend port turns out to be something like3001or any other port.
We first need to setup the frontend env variables. Start by running the command:
cp .env.example .envThen, edit the .env file just created and fill the NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY variable with the Publishable Key you just saved from Clerk.
Now you can start the Nuxt frontend by running the command:
bun run devThe application should open at http://localhost:3000.
Note that, in production, you should also set the NUXT_PUBLIC_CONVEX_URL variable to the Deployment URL exposed in the Convex dashboard.
Shadcn for Vue is already configured, and you can add any component by running the following command:
bun run components:add <component>Replace <component> for input, for example.
Note that the button component comes already installed, you can re-install it if you want.
A very rudimentary structure for Users and Organizations comes built-in. This structure is basically a collection of users (with some personal data, auto-filled from the Clerk webhooks) where each user can be associated to an organization, and has a role (admin or member).
Whenever a Clerk webhook of user creation gets received by the backend, it gets the domain of the main email, then searches for an organization corresponding to said domain. If no organization exists, the user gets created without an associated organization (and can do nothing). If the organization exists and there is no other admin, the user will automatically be associated to the organization as an admin. If other admins exist, the user will automatically be associated as a member.
Note that Organizations must be manually created.
Data migrations will probably be necessary throughout your development of the application. There are 2 typical types of data migrations (maybe more, idk, these are the ones I usually need):
- "Existing data alteration" migrations. These migrations are the ones that take each row of a table and transform some column value into another thing, or that fill an empty column for every row of a table.
- "Importing data" migrations. These migrations are the ones that you use when there is an empty table and you need to add a bunch of rows to it.
The @convex-dev/migrations component supports the first scenario mostly out of the box. The convex/migrations/000_example_migration/alterData.ts file in the template shows a data migration that goes over every row in the users table and runs some code.
As for the second scenario, this template includes an empty table named bulkMigrationsHelper and a singular seed in seeds/bulk-migrations-helper.jsonl that generates one row on the empty table. It is VERY IMPORTANT that this table has one and only one row. The convex/migrations/000_example_migration/importData.ts file in the template shows how to import an arbitrary amount of data. We use this helper row to run the migration code ONCE. For this to work, you need to make sure to have one and only one row on every environment where you run your migrations. Creating a row by hand on the Convex dashboard is also fine, just make sure there is no less, no more than 1 row for the helper table for this to work.
You can run the migrations via CLI with the following command:
bun run convex:migrateYou can also run them by going to the dashboard, entering the Functions section of the deployment and running the migrations/index:all function manually from there.
In both cases, migrations will only run if they haven't been run before.
There is a very opinionated folder structure regarding the frontend part of the application that makes it easier to scale the app.
The pages folder should contain components with the least amount of code in them. When applications grow, having code grouped by domain is better than having it grouped by pages and components and composables.
GitHub Actions come baked-in for PRs and merges to main. 2 actions will run: a Linter (already configured), and a TS build.
You can validate a user exists in the backend by using the validateUserPresence helper from ~/convex/utils/auth. Here is an example:
import { v } from 'convex/values';
import { query } from '~/convex/_generated/server';
import { validateUserPresence } from '~/convex/utils/auth';
export const someProtectedQuery = query({
args: { parameter: v.string() },
handler: async (ctx, { parameter }) => {
const user = await validateUserPresence(ctx, { validate: { role: 'admin' } });
console.log(`User ${user.name} is an admin and the parameter is ${parameter}`);
},
});The validateUserPresence will return the user authenticated via Clerk that's calling the query, and will throw an error if the user is authenticated.
To use a query/mutation/action, you need to write something like this:
import { useConvexMutation } from '@adinvadim/convex-vue';
import { api } from '~/convex/_generated/api';
export const useSomeRandomMutation = () => {
return useConvexMutation(api.resource.addOne, {
optimisticUpdate: (ctx, newResource) => {
// Optimistically update the UI
},
onSuccess: () => {
// Handle success
},
onError: (error) => {
// Handle error
},
});
};The optimisticUpdte runs after starting the mutation, and gets auto-resolved when the mutation settles.
The onSuccess hook runs after the mutation succeeds.
The onError one runs after the mutation fails. If you are throwing custom-made errors with the convexError helper defined in convex/utils/errors.ts (by doing throw convexError({ code: 'some_code' })), you can then narrow the error type with the isConvexError helper defined in domains/errors/utils.ts (by checking if (isConvexError(error)) { /* handle error */ }). A fully working example could look like this:
import { useConvexMutation } from '@adinvadim/convex-vue';
import { toast } from 'vue-sonner';
import { api } from '~/convex/_generated/api';
import { isConvexError } from '~/domains/errors/utils';
export const useSomeRandomMutation = () => {
return useConvexMutation(api.resource.addOne, {
optimisticUpdate: (ctx, newResource) => {
const previousData = ctx.getQuery(api.resource.list, { some: 'param' });
ctx.setQuery(api.resource.list, { some: 'param' }, [...previousData, newResource]);
},
onSuccess: () => {
toast.success('Mutation executed successfully', { richColors: true });
},
onError: (error) => {
if (isConvexError(error) && error.data.code === 'some_code') {
toast.error('This error was expected', { richColors: true });
} else {
toast.error('Something went wrong', { richColors: true });
}
},
});
};Deployment of this code in Netlify or Vercel is simple. First, add the required env variables:
CONVEX_DEPLOY_KEY: A deploy key for the Convex project. Must be generated inside the Convex dashboard.NUXT_PUBLIC_CONVEX_URL: The Deployment URL from the Convex project.NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY: The publishable key of the Clerk project.
Then, configure the Netlify/Vercel project to run the following build command:
bunx convex deploy --cmd "bun run build"