Blitz is in beta! 🎉 1.0 expected in May or June
Back to Documentation Menu

Tutorial

Topics

Jump to a Topic

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”.

Creating a new app

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.

The development server

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!

Sign up as a user

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.

Write your first page

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.

Database setup

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.

Scaffolding code for our models

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.

First, we'll generate everything pertaining to the 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.

Next we'll generate the 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

Lastly let's update the 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!

Playing with the Prisma database 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?"
  }
]

Update generated code for our model attributes

Info

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.

Question 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 mutation

In 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 mutation

In 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 mutation

Prisma 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.

Now try creating, updating, and deleting questions

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.

Adding choices to the question form

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
  },
)

Try it out

Now you can go to localhost:3000/questions/new and create a new question with choices!

Listing 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!

Let’s let people vote on these questions!

Open app/pages/questions/[questionId].tsx in your editor. First, we’re going to improve this page somewhat.

  1. Replace <h1>Question {question.id}</h1> with <h1>{question.text}</h1>.

  2. 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!

Screenshot

Now it’s time to add voting!

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>
    </>
  )
}

Lastly, let's allow editing choices for an existing question

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.

Cleanup

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.

Conclusion

🥳 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:

  • Adding styling (Hint, try blitz install tailwind or blitz install chakra-ui)
  • Showing some more statistics about votes
  • Deploying live on Render or Vercel

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!


Idea for improving this page? Edit it on GitHub.