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 -vIf 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-appBlitz will create a my-blitz-app folder in your current folder. You will
be prompted to pick a form library. For this tutorial, select the
recommended library React Final Form.
Let’s look at what blitz new created:
my-blitz-app
├── app/
│ ├── api/
│ ├── 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
│ │ ├── pages/
│ │ │ ├── forgot-password.tsx
│ │ │ ├── login.tsx
│ │ │ ├── reset-password.tsx
│ │ │ └── signup.tsx
│ │ └── validations.ts
│ ├── core/
│ │ ├── components/
│ │ │ ├── Form.tsx
│ │ │ └── LabeledTextField.tsx
│ │ ├── hooks/
│ │ │ └── useCurrentUser.ts
│ │ └── layouts/
│ │ └── Layout.tsx
│ ├── pages/
│ │ ├── 404.tsx
│ │ ├── _app.tsx
│ │ ├── _document.tsx
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ └── users/
│ └── queries/
│ └── getCurrentUser.ts
├── db/
│ ├── index.ts
│ ├── schema.prisma
│ └── seeds.ts
├── integrations/
├── mailers/
│ └── forgotPasswordMailer.ts
├── public/
│ ├── favicon.ico*
│ └── logo.png
├── test/
│ ├── setup.ts
│ └── utils.tsx
├── README.md
├── babel.config.js
├── blitz.config.js
├── jest.config.js
├── package.json
├── tsconfig.json
├── types.d.ts
├── types.ts
└── yarn.lockThese files are:
The app/ folder is a container for most of your project. This is where
you’ll put any pages or API routes.
The app/pages/ folder is the primary pages folder. If you've used
Next.js you'll immediately notice this is different. In Blitz, you can
have many pages folders and they will be merged together at build
time.
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.
.babelrc.js, .env, etc. ("dotfiles") are configuration files for
various bits of JavaScript tooling.
blitz.config.js is for advanced custom configuration of Blitz. It's
the same format as next.config.js.
tsconfig.json is our recommended setup for TypeScript.
Now make sure you are in the my-blitz-app folder, if you haven’t
already, and run the following command:
blitz devYou’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
info - Using external babel configuration from /my-blitz-app/babel.config.js
event - compiled successfullyNow 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 app/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 add 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:stringAnd 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 app/pages/questions/[questionId].tsx
CREATE app/pages/questions/[questionId]/edit.tsx
CREATE app/pages/questions/index.tsx
CREATE app/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 for 'question' created in schema.prisma:
> model Question {
> id Int @default(autoincrement()) @id
> createdAt DateTime @default(now())
> updatedAt DateTime @updatedAt
> text String
> }
? Run 'prisma migrate dev' to update your database? (Y/n) › trueEnvironment 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 question
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20210217035805_add_question/
└─ migration.sql
✔ Generated Prisma Client (2.17.0) to ./node_modules/@prisma/client in 103ms
Everything is now in sync.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:questionIf 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) › falseQuestion 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 devAnd 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!
Now, let’s hop into the interactive Blitz shell and play around with the Prisma database client that Blitz gives you. To start the Blitz console, use this command:
blitz consoleOnce you’re in the console, explore the database client:
# No questions are in the system yet.
⚡ > await db.question.findMany()
[]
# Create a new Question:
⚡ > let q = await db.question.create({data: {text: "What's new?"}})
undefined
# See the entire object:
⚡ > q
{
id: 1,
createdAt: 2020-06-15T15:06:14.959Z,
updatedAt: 2020-06-15T15:06:14.959Z,
text: "What's new?"
}
# Or, access individual values on the object:
⚡ > q.text
"What's new?"
# Change values by using the update function:
⚡ > q = await db.question.update({where: {id: 1}, data: {text: "What's up?"}})
{
id: 1,
createdAt: 2020-06-15T15:06:14.959Z,
updatedAt: 2020-06-15T15:13:17.394Z,
text: "What's up?"
}
# db.question.findMany() now displays all the questions in the database:
⚡ > await db.question.findMany()
[
{
id: 1,
createdAt: 2020-06-15T15:06:14.959Z,
updatedAt: 2020-06-15T15:13:17.394Z,
text: "What's up?"
}
]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 app/pages/questions/index.tsx. Notice that a
QuestionsList component has been generated for you:
// app/pages/questions/index.tsx
export const QuestionsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{questions, hasMore}] = usePaginatedQuery(getQuestions, {
orderBy: {id: "asc"},
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
})
const goToPreviousPage = () => router.push({query: {page: page - 1}})
const goToNextPage = () => router.push({query: {page: page + 1}})
return (
<div>
<ul>
{questions.map((question) => (
<li key={question.id}>
<Link href={`/questions/${question.id}`}>
<a>{question.name}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button disabled={!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:
// app/pages/questions/index.tsx
const QuestionsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{questions, hasMore}] = usePaginatedQuery(getQuestions, {
orderBy: {id: "asc"},
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
})
const goToPreviousPage = () => router.push({query: {page: page - 1}})
const goToNextPage = () => router.push({query: {page: page + 1}})
return (
<div>
<ul>
{questions.map((question) => (
<li key={question.id}>
<Link href={`/questions/${question.id}`}>
- <a>{question.name}</a>
+ <a>{question.text}</a>
</Link>
</li>
))}
</ul>
<button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</button>
<button disabled={!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(),
})
.nonstrict()
// ...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(),
})
.nonstrict()
// ...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.
Great! Now make sure your app is running. If it isn’t, run 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>
)
}Next open app/pages/questions/new.tsx and set initialValues to be as
follows:
<QuestionForm
submitText="Create Question"
- // initialValues={{ }}
+ initialValues={{choices: []}}
onSubmit={async (values) => {
try {
const question = await createQuestionMutation(values)
router.push(`/questions/${question.id}`)
} catch (error) {
console.error(error)
return {
[FORM_ERROR]: error.toString(),
}
}
}}
/>Then open app/questions/mutations/createQuestion.ts and update the zod
schema so that the choice data is accepted in the mutation. And we also
need to update the db.question.create call so that the choices will also
be created.
// app/questions/mutations/createQuestion.ts
const CreateQuestion = z
.object({
text: z.string(),
+ choices: z.array(z.object({text: z.string()})),
})
.nonstrict()
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
},
)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
(app/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:
// app/pages/questions/index.tsx
// ...
{
questions.map((question) => (
<li key={question.id}>
<Link href={`/questions/${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 app/pages/questions/[questionId].tsx in your editor. First, we’re
going to improve this page somewhat.
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 add a vote increment.
const UpdateChoice = z
.object({
id: z.number(),
- name: z.string(),
})
.nonstrict()
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 app/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:
// app/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:
// app/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={`/questions/${question.id}/edit`}>
<a>Edit</a>
</Link>
<button
type="button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteQuestionMutation({id: question.id})
router.push("/questions")
}
}}
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()}).nonstrict(),
+ ),
})
.nonstrict()
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},
+ })),
+ },
+ },
+ })
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:
blitz install tailwind or
blitz install chakra-ui)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!