In this tutorial, we’ll walk you through the creation of a basic voting application.
We’ll assume that you have Blitz installed already. You can tell if Blitz is installed, and which version you have by running the following command in your terminal:
blitz -v
If Blitz is installed, you should see the version of your installation. If it isn’t, you’ll get an error saying something like “command not found: blitz”.
From the command line, cd
into the folder where you’d like to create
your app, and then run the following command:
blitz new my-blitz-app
Blitz will create a my-blitz-app
folder in your current folder. You will
be asked how you want your new app to be. For this tutorial, select all
the default values by only pressing Enter when asked (you'll create a
Full Blitz app with TypeScript, npm and React Final Form).
Let’s look at what blitz new
created:
my-blitz-app
├── app/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignupForm.tsx
│ │ ├── mutations/
│ │ │ ├── changePassword.ts
│ │ │ ├── forgotPassword.test.ts
│ │ │ ├── forgotPassword.ts
│ │ │ ├── login.ts
│ │ │ ├── logout.ts
│ │ │ ├── resetPassword.test.ts
│ │ │ ├── resetPassword.ts
│ │ │ └── signup.ts
│ │ └── validations.ts
│ ├── core/
│ │ ├── components/
│ │ │ ├── Form.tsx
│ │ │ └── LabeledTextField.tsx
│ │ └── layouts/
│ │ └── Layout.tsx
│ ├── users/
│ │ ├── hooks/
│ │ │ └── useCurrentUser.ts
│ │ └── queries/
│ │ └── getCurrentUser.ts
│ ├── blitz-client.ts
│ └── blitz-server.ts
├── db/
│ ├── migrations/
│ ├── index.ts
│ ├── schema.prisma
│ └── seeds.ts
├── integrations/
├── mailers/
│ └── forgotPasswordMailer.ts
├── pages/
│ ├── api/
│ │ └── rpc/
│ │ └── [[...blitz]].ts
│ ├── auth/
│ │ └── forgot-password.tsx
│ │ └── login.tsx
│ │ └── signup.tsx
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── 404.tsx
│ └── index.tsx
├── public/
│ ├── favicon.ico*
│ └── logo.png
├── test/
│ ├── setup.ts
├── README.md
├── next.config.js
├── jest.config.js
├── package.json
├── tsconfig.json
├── types.d.ts
├── types.ts
└── yarn.lock
These files are:
The app/
folder has Blitz setup files — blitz-client.ts
and
blitz-server.ts
. This is also where you’ll put any queries/mutations
or some of your components.
The pages/
folder is the primary pages folder. You'll put all your
pages and API routes here.
The app/core/
folder is the main place to put components, hooks, etc
that are used throughout your app.
db/
is where your database configuration goes. If you’re writing
models or checking migrations, this is where to go.
public/
is a folder where you will put any static assets. If you have
images, files, or videos which you want to use in your app, this is
where to put them.
.npmrc
, .env
, etc. ("dotfiles") are configuration files for various
bits of JavaScript tooling.
next.config.js
is for advanced custom configuration of Blitz and
Next.js.
tsconfig.json
is our recommended setup for TypeScript.
You can read more about the file structure here.
Now make sure you are in the my-blitz-app
folder, if you haven’t
already, and run the following command:
blitz dev
You’ll see the following output on the command line:
✔ Compiled
Loaded env from /private/tmp/my-blitz-app/.env
warn - You have enabled experimental feature(s).
warn - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use them at your own risk.
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled successfully
Now that the server’s running, visit localhost:3000 with your web browser. You’ll see a welcome page, with the Blitz logo. It worked!
Blitz apps are created with user signup and login already set up! So let's
try it. Click on the Sign Up button. Enter any email and password and
click Create Account. Then you'll be redirected back to the home page
where you can see your user id
and role
.
If you want, you can also try logging out and logging back in. Or click Forgot your password? on the login page to try that flow.
Next let's create your first page.
Open the file pages/index.tsx
and replace the contents of the Home
component with this:
//...
const Home: BlitzPage = () => {
return (
<div>
<h1>Hello, world!</h1>
<Suspense fallback="Loading...">
<UserInfo />
</Suspense>
</div>
)
}
//...
Save the file and you should see the page update in your browser. You can customize this as much as you want. When you’re ready, move on to the next section.
Good news, an SQLite database was already set up for you! You can run
blitz prisma studio
in the terminal to open a web interface where you
can see the data in your database.
Note that when starting your first real project, you may want to use a more scalable database like PostgreSQL, to avoid the pains of switching your database down the road. For more information, see Database overview. For now, we will continue with the default SQLite database.
Blitz provides a handy CLI command called generate
for
scaffolding out boilerplate code. We'll use generate
to create two
models: Question
and Choice
. A Question
has the text of the question
and a list of choices. A Choice
has the text of the choice, a vote
count, and an associated question. Blitz will automatically generate an
id, a creation timestamp, and a last updated timestamp for both models.
Question
model:blitz generate all question text:string
And when prompted, press the Enter to run prisma migrate
which will
update your database schema with the new model. It will ask for a name, so
type something like "add question".
CREATE pages/questions/[questionId].tsx
CREATE pages/questions/[questionId]/edit.tsx
CREATE pages/questions/index.tsx
CREATE pages/questions/new.tsx
CREATE app/questions/components/QuestionForm.tsx
CREATE app/questions/queries/getQuestion.ts
CREATE app/questions/queries/getQuestions.ts
CREATE app/questions/mutations/createQuestion.ts
CREATE app/questions/mutations/deleteQuestion.ts
CREATE app/questions/mutations/updateQuestion.ts
✔ Model 'Question' created in schema.prisma:
>
> model Question {
> id Int @id @default(autoincrement())
> createdAt DateTime @default(now())
> updatedAt DateTime @updatedAt
> text String
> }
>
✔ Run 'prisma migrate dev' to update your database? (Y/n) · true
Environment variables loaded from .env
Prisma schema loaded from db/schema.prisma
Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"
✔ Enter a name for the new migration: … add question
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20210722070215_add_question/
└─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (4.0.0) to ./node_modules/@prisma/client in 187ms
The generate
command with a type of all
generates a model and queries,
mutation and page files. See the Blitz generate page for
a list of available type options.
Choice
model with corresponding queries and mutations.We'll pass a type of resource
this time as we don't need to generate
pages for the Choice
model:
blitz generate resource choice text votes:int:default=0 belongsTo:question
If you get an error run blitz prisma format
Note that this doesn't require a database migration because we haven't
added the Choice
field to the Question
model yet. So we are choosing
false
when prompted to run the migration:
CREATE app/choices/queries/getChoice.ts
CREATE app/choices/queries/getChoices.ts
CREATE app/choices/mutations/createChoice.ts
CREATE app/choices/mutations/deleteChoice.ts
CREATE app/choices/mutations/updateChoice.ts
✔ Model for 'choice' created in schema.prisma:
> model Choice {
> id Int @default(autoincrement()) @id
> createdAt DateTime @default(now())
> updatedAt DateTime @updatedAt
> text String
> votes Int @default(0)
> question Question @relation(fields: [questionId], references: [id])
> questionId Int
> }
? Run 'prisma migrate dev' to update your database? (Y/n) › false
Question
model to have a relationship back to Choice
.Open db/schema.prisma
and add choices Choice[]
to the Question
model.
model Question {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
text String
+ choices Choice[]
}
Now we can run the migration to update our database:
blitz prisma migrate dev
And again, enter a name for the migration, like "add choice":
Environment variables loaded from .env
Prisma schema loaded from db/schema.prisma
Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"
✔ Name of migration … add choice
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20210412175528_add_choice/
└─ migration.sql
Your database is now in sync with your schema.
Now our database is ready and a Prisma client is also generated. Lets move on to play with the Prisma client!
Before running the app again, we need to customize some of the code that has been generated. Ultimately, these fixes will not be needed - but for now, we need to work around a couple outstanding issues.
The generated page content does not currently use the actual model attributes you defined during generation. It will soon, but in the meantime, let's fix the generated pages.
Jump over to pages/questions/index.tsx
. Notice that a QuestionsList
component has been generated for you:
// pages/questions/index.tsx
export const QuestionsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{ questions, hasMore }, { isPreviousData }] = usePaginatedQuery(
getQuestions,
{
orderBy: { id: "asc" },
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
}
)
const goToPreviousPage = () =>
router.push({ query: { page: page - 1 } })
const goToNextPage = () => {
if (!isPreviousData && hasMore) {
router.push({ query: { page: page + 1 } })
}
}
return (
<div>
<ul>
{questions.map((question) => (
<li key={question.id}>
<Link
href={Routes.ShowQuestionPage({ questionId: question.id })}
>
<a>{question.name}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button
disabled={isPreviousData || !hasMore}
onClick={goToNextPage}
>
Next
</button>
</div>
)
}
This won’t work though! Remember that the Question
model we created
above doesn’t have any name
field. To fix this, replace question.name
with question.text
:
// pages/questions/index.tsx
const QuestionsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{questions, hasMore}, {isPreviousData}] = usePaginatedQuery(
getQuestions, {
orderBy: {id: "asc"},
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
},
)
const goToPreviousPage = () => router.push({query: {page: page - 1}})
const goToNextPage = () => {
if (!isPreviousData && hasMore) {
router.push({query: {page: page + 1}})
}
}
return (
<div>
<ul>
{questions.map((question) => (
<li key={question.id}>
<Link href={Routes.ShowQuestionPage({ questionId: question.id })}>
- <a>{question.name}</a>
+ <a>{question.text}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button disabled={isPreviousData || !hasMore} onClick={goToNextPage}>
Next
</button>
</div>
)
}
Next, let’s apply a similar fix to
app/questions/components/QuestionForm.tsx
. In the form submission,
replace the LabeledTextField
name
to be "text"
export function QuestionForm<S extends z.ZodType<any, any>>(
props: FormProps<S>,
) {
return (
<Form<S> {...props}>
- <LabeledTextField name="name" label="Name" placeholder="Name" />
+ <LabeledTextField name="text" label="Text" placeholder="Text" />
</Form>
)
}
createQuestion
mutationIn app/questions/mutations/createQuestion.ts
, we need to update the
CreateQuestion
zod validation schema to use text
instead of name
.
// app/questions/mutations/createQuestion.ts
const CreateQuestion = z
.object({
- name: z.string(),
+ text: z.string(),
})
// ...
updateQuestion
mutationIn app/questions/mutations/updateQuestion.ts
, we need to update the
UpdateQuestion
zod validation schema to use text
instead of name
.
// app/questions/mutations/updateQuestion.ts
const UpdateQuestion = z
.object({
id: z.number(),
- name: z.string(),
+ text: z.string(),
})
deleteQuestion
mutationPrisma does not yet support "cascading deletes". In the context of this
tutorial, that means it does not currently delete the Choice
data when
deleting a Question
. We need to temporarily augment the generated
deleteQuestion
mutation in order to do this manually. Open up
app/questions/mutations/deleteQuestion.ts
in your text editor and add
the following to the top of the function body:
await db.choice.deleteMany({ where: { questionId: id } })
The end result should be as such:
// app/questions/mutations/deleteQuestion.ts
export default resolver.pipe(
resolver.zod(DeleteQuestion),
resolver.authorize(),
async ({id}) => {
+ await db.choice.deleteMany({where: {questionId: id}})
const question = await db.question.deleteMany({where: {id}})
return question
},
)
This mutation will now delete the choices associated with the question prior to deleting the question itself.
updateChoice
mutationIn app/choices/mutations/updateChoice.ts
, we need to update the
UpdateChoice
zod validation schema to use text
instead of name
.
// app/choices/mutations/updateChoice.ts
const UpdateChoice = z
.object({
id: z.number(),
- name: z.string(),
+ text: z.string(),
})
Our scaffolding created a mutation file for us that is no longer needed.
In order for yarn tsc
or git push
to succeed, you'll need to delete
app/choices/mutations/createChoice.ts
(unused) or update the
CreateChoice zod schema to include the required fields.
Great! Now make sure you stop the application, start it again with
blitz dev
in your terminal, and visit localhost:3000/questions
. Try
creating questions, editing, and deleting them.
You’re doing great so far! The next thing we’ll do is add choices to our
question form. Open app/questions/components/QuestionForm.tsx
in your
editor.
Add three more <LabeledTextField>
components as choices.
export function QuestionForm<S extends z.ZodType<any, any>>(
props: FormProps<S>,
) {
return (
<Form<S> {...props}>
<LabeledTextField name="text" label="Text" placeholder="Text" />
+ <LabeledTextField name="choices.0.text" label="Choice 1" />
+ <LabeledTextField name="choices.1.text" label="Choice 2" />
+ <LabeledTextField name="choices.2.text" label="Choice 3" />
</Form>
)
}
Now open app/questions/mutations/createQuestion.ts
and update the zod
schema so that the choice data is accepted in the mutation. We also need
to update the db.question.create()
call so that the choices will be
created. After that we need to export the CreateQuestion
zod schema
because we will be using it in the next step to create a validation schema
for our QuestionForm
.
// app/questions/mutations/createQuestion.ts
+ export const CreateQuestion = z
.object({
text: z.string(),
+ choices: z.array(z.object({text: z.string()})),
})
export default resolver.pipe(
resolver.zod(CreateQuestion),
resolver.authorize(),
async (input) => {
- const question = await db.question.create({data: input})
+ const question = await db.question.create({
+ data: {
+ ...input,
+ choices: {create: input.choices},
+ },
+ })
return question
},
)
Next we're going to create a separate file to store the validation schema
for our QuestionForm
. In the app/questions
folder create a new file
called validations.ts
and move the CreateQuestion
variable from
./mutations/createQuestion.ts
to the new validations.ts
file. Then, in
app/questions/mutations/createQuestion.ts
import CreateQuestion
from
../validations
.
// app/questions/validations.ts
+ import * as z from 'zod';
+ export const CreateQuestion = z.object({
+ text: z.string(),
+ choices: z.array(z.object({ text: z.string() }))
+ });
// app/questions/mutations/createQuestion.ts
import { resolver } from '@blitzjs/rpc';
import db from 'db';
- import { z } from 'zod';
+ import { CreateQuestion } from '../validations';
- const CreateQuestion = z.object({
- text: z.string(),
- choices: z.array(z.object({ text: z.string() }))
- });
export default resolver.pipe(resolver.zod(CreateQuestion), resolver.authorize(), async (input) => {
// TODO: in multi-tenant app, you must add validation to ensure correct tenant
const question = await db.question.create({
data: {
...input,
choices: { create: input.choices }
}
});
return question;
});
We create a shared validations.ts
file because we cannot import
anything from a query (or mutation) file other than the query itself
into the client. You can read more about why in Query
Usage and Mutation
Usage.
Now open pages/questions/new.tsx
and import CreateQuestion
from
app/questions/validations.ts
and set it as the schema for
QuestionForm
. Also, we need set {{text: "", choices: []}}
as our
initialValues
for QuestionForm
:
// pages/questions/new.tsx
+ import {CreateQuestion} from "app/questions/validations"
<QuestionForm
submitText="Create Question"
- // * use a zod schema for form validation
- // - Tip: extract mutation's schema into a shared `validations.ts` file and
- // then import and use it here
- // schema={createQuestion}
- // initialValues={{ }}
+ schema={CreateQuestion}
+ initialValues={{text: "", choices: []}}
onSubmit={async (values) => {
try {
const question = await createQuestionMutation(values)
router.push(Routes.ShowQuestionPage({ questionId: question.id }))
} catch (error) {
console.error(error)
return {
[FORM_ERROR]: error.toString(),
}
}
}}
/>
Now you can go to localhost:3000/questions/new
and create a new question
with choices!
Time for a breather. Go back to localhost:3000/questions
in your browser
and look at all the questions you‘ve created. How about we list these
questions’ choices here too? First, we need to customize the question
queries. In Prisma, you need to manually let the client know that you want
to query for nested relations. Change your getQuestion.ts
and
getQuestions.ts
files to look like this:
// app/questions/queries/getQuestion.ts
const GetQuestion = z.object({
// This accepts type of undefined, but is required at runtime
id: z.number().optional().refine(Boolean, "Required"),
})
export default resolver.pipe(
resolver.zod(GetQuestion),
resolver.authorize(),
async ({id}) => {
- const question = await db.question.findFirst({where: {id}})
+ const question = await db.question.findFirst({
+ where: {id},
+ include: {choices: true},
+ })
if (!question) throw new NotFoundError()
return question
},
)
// app/questions/queries/getQuestions.ts
interface GetQuestionsInput
extends Pick<
Prisma.QuestionFindManyArgs,
"where" | "orderBy" | "skip" | "take"
> {}
export default resolver.pipe(
resolver.authorize(),
async ({where, orderBy, skip = 0, take = 100}: GetQuestionsInput) => {
const {items: questions, hasMore, nextPage, count} = await paginate({
skip,
take,
count: () => db.question.count({where}),
query: (paginateArgs) =>
db.question.findMany({
...paginateArgs,
where,
orderBy,
+ include: {choices: true},
}),
})
return {
questions,
nextPage,
hasMore,
count,
}
},
)
Now hop back to our main questions page (pages/questions/index.tsx
)in
your editor, and we can list the choices of each question. And add this
code beneath the Link
in our QuestionsList
:
// pages/questions/index.tsx
// ...
{
questions.map((question) => (
<li key={question.id}>
<Link href={Routes.ShowQuestionPage({ questionId: question.id })}>
<a>{question.text}</a>
</Link>
+ <ul>
+ {question.choices.map((choice) => (
+ <li key={choice.id}>
+ {choice.text} - {choice.votes} votes
+ </li>
+ ))}
+ </ul>
</li>
))
}
// ...
Now check /questions
in the browser. Magic!
Open pages/questions/[questionId].tsx
in your editor. First, we’re going
to improve this page somewhat.
Replace <title>Question {question.id}</title>
with
<title>{question.text}</title>
.
Replace <h1>Question {question.id}</h1>
with
<h1>{question.text}</h1>
.
Delete the pre
element, and copy in our choices list which we wrote
before:
<ul>
{question.choices.map((choice) => (
<li key={choice.id}>
{choice.text} - {choice.votes} votes
</li>
))}
</ul>
If you go back to your browser, your page should now look something like this!
First we need to open app/choices/mutations/updateChoice.ts
, update the
zod schema, and add a vote increment.
const UpdateChoice = z
.object({
id: z.number(),
- text: z.string(),
})
export default resolver.pipe(
resolver.zod(UpdateChoice),
resolver.authorize(),
async ({id, ...data}) => {
- const choice = await db.choice.update({where: {id}, data})
+ const choice = await db.choice.update({
+ where: {id},
+ data: {votes: {increment: 1}},
+ })
return choice
},
)
Now go back to pages/questions/[questionId].tsx
and make the following
changes:
In our li
, add a button
like so:
<li key={choice.id}>
{choice.text} - {choice.votes} votes
<button>Vote</button>
</li>
Then, import the updateChoice
mutation we updated and create a
handleVote
function in our page:
// pages/questions/[questionId].tsx
+import updateChoice from "app/choices/mutations/updateChoice"
//...
const Question = () => {
const router = useRouter()
const questionId = useParam("questionId", "number")
const [deleteQuestionMutation] = useMutation(deleteQuestion)
const [question] = useQuery(getQuestion, {id: questionId})
+ const [updateChoiceMutation] = useMutation(updateChoice)
+
+ const handleVote = async (id: number) => {
+ try {
+ await updateChoiceMutation({id})
+ refetch()
+ } catch (error) {
+ alert("Error updating choice " + JSON.stringify(error, null, 2))
+ }
+ }
return (
And then we need to update the question useQuery
call to return the
refetch
function which we use inside handleVote
:
// pages/questions/[questionId].tsx
//...
- const [question] = useQuery(getQuestion, {id: questionId})
+ const [question, {refetch}] = useQuery(getQuestion, {id: questionId})
//...
Finally, we’ll tell our new button
to call that function!
<button onClick={() => handleVote(choice.id)}>Vote</button>
The final Question
component should now look like this:
export const Question = () => {
const router = useRouter()
const questionId = useParam("questionId", "number")
const [deleteQuestionMutation] = useMutation(deleteQuestion)
const [question, { refetch }] = useQuery(getQuestion, {
id: questionId,
})
const [updateChoiceMutation] = useMutation(updateChoice)
const handleVote = async (id: number) => {
try {
await updateChoiceMutation({ id })
refetch()
} catch (error) {
alert("Error updating choice " + JSON.stringify(error, null, 2))
}
}
return (
<>
<Head>
<title>Question {question.id}</title>
</Head>
<div>
<h1>{question.text}</h1>
<ul>
{question.choices.map((choice) => (
<li key={choice.id}>
{choice.text} - {choice.votes} votes
<button onClick={() => handleVote(choice.id)}>Vote</button>
</li>
))}
</ul>
<Link href={Routes.EditQuestionPage({ questionId: question.id })}>
<a>Edit</a>
</Link>
<button
type="button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteQuestionMutation({ id: question.id })
router.push(Routes.QuestionsPage())
}
}}
style={{ marginLeft: "0.5rem" }}
>
Delete
</button>
</div>
</>
)
}
If you click the Edit button on one of your existing questions, you'll see it uses the same form as creating questions. So that part is already done! We only need to update our mutation.
Open app/questions/mutations/updateQuestion.ts
and make the following
changes:
// app/questions/mutations/updateQuestion.ts
import {resolver} from "blitz"
import db from "db"
import * as z from "zod"
const UpdateQuestion = z
.object({
id: z.number(),
text: z.string(),
+ choices: z.array(
+ z.object({id: z.number().optional(), text: z.string()}),
+ ),
})
export default resolver.pipe(
resolver.zod(UpdateQuestion),
resolver.authorize(),
async ({id, ...data}) => {
- const question = await db.question.update({where: {id}, data})
+ const question = await db.question.update({
+ where: {id},
+ data: {
+ ...data,
+ choices: {
+ upsert: data.choices.map((choice) => ({
+ // Appears to be a prisma bug,
+ // because `|| 0` shouldn't be needed
+ where: {id: choice.id || 0},
+ create: {text: choice.text},
+ update: {text: choice.text},
+ })),
+ },
+ },
+ include: {
+ choices: true,
+ },
+ })
return question
},
)
upsert
is a special operation that means, "If this item exists, update it. Else
create it". This is perfect for this case because we didn't require the
user to add three choices when creating the question. So if later the user
adds another choice by editing the question, then it'll be created here.
🥳 Congrats! You created your very own Blitz app! Have fun playing around with it, or sharing it with your friends. Now that you’ve finished this tutorial, why not try making your voting app even better? You could try:
If you want to share your project with the world wide Blitz community there is no better place to do that than on Discord.
Visit discord.blitzjs.com. Then, post the link to the #built-with-blitz channel to share it with everyone!