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 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.lock
These 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 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
info - Using external babel configuration from /my-blitz-app/babel.config.js
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 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 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 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) › true
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 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: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!
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 console
Once 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 }, { 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={`/questions/${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
:
// app/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={`/questions/${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(),
})
.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 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},
+ })),
+ },
+ },
+ 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.
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.
🥳 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!