This content originally appeared on DEV Community and was authored by RealGuillaume
As developers, we're always looking for ways to optimize our web apps development process and reduce the tedious repeating of manual work.
From the early days of Ruby on Rails to the takeover by React, we've seen new frameworks and architecture streamline the way we work.
And now, a relatively new fullstack JavaScript framework has come into the picture to address a key pain point that Jamstack developers are facing today. I'm talking about Blitz.js, the "batteries-included" framework. Because nobody misses the good old days when buying a brand new "portable" CD player meant you also had to think about AA batteries.
Here's what we'll explore today:
- What is Blitz.js?
- Why using Blitz.js instead of Next.js?
- How to build a web app with Blitz.js?
Let's dive right into it!
What is Blitz.js?
Blitz.js is a fullstack framework created by Brandon Bayer and built on top of Next.js (which in itself is built on top of React 🤯). It is designed as a monolithic foundation that includes everything from database communication to frontend development. No need for REST/GraphQL.
It utilizes a "zero-API" concept that bypasses the need to write an API layer in the frontend. Instead of fetching the data from your backend, you write queries and mutations, automatically generating the frontend API layer after building the project. External apps can also use this API. It also comes integrated with commonly used developer plugins such as ESLint and Prettier, to name a few.
"But wait, I'm a Jamstack developer. I don't want a monolithic solution. I want freedom."
Well, here comes Blitz, the agnostic monolith. Take the database, for example - Blitz comes out-of-the-box with Prisma 2. However, you're free to switch to another one like Fauna or DynamoDB. The same goes for the configuration; deciding a folder structure, defining routing conventions, selecting a styling library, and adding authorization and authentication are all set up by default, but that doesn't mean you cannot go your own way.
Working with Blitz accelerates development by scaffolding code structure based on models, covering everything from database migrations to frontend forms.
It also takes on a concept introduced by Gatsby called "Recipes". Blitz.js lends a hand to developers by simplifying the integration of third-party dependencies using just a single command.
Blitz web app architecture via "blitzjs.com"
This means that you won't need any other frameworks, tools, or APIs to build a web app. Now, all can be done using Blitz.js alone.
TL;DR
Blitz.js is a fullstack framework built on Next.js that lets you import server code directly into React components.
Here are the key features of Blitz:
- Fullstack & Monolithic
You only have one code base to develop and deploy. Everything from the backend to the frontend is done within this framework, and you don't need to assemble multiple layers across the stack to build your app.
- APIs not required
You can simply write functions that run on the server and import them directly in your React components in your frontend if needed.
- Convention over configuration
Configuration is optional. Everything from authentification to authorization is set up by default.
Why use Blitz.js instead of Next.js?
That's all great, but what exactly makes Blitz.js different from Next.js, and why should I build an app using Blitz instead?
As you know, Blitz.js is built on Next.js so, it can do everything Next.js can, with the addition of extra features bringing more flexibility and fewer headaches :
- Blitz.js is a fullstack database-agnostic framework, whereas Next.js requires you to manually set up a database and API to achieve the same result. This makes Blizt.js a more convenient choice while remaining flexible (which is paramount).
- The "Zero-API" data layer of Blitz.js creates an effortless and powerful end-to-end data layer by allowing you to import your server code directly into your components. It will then automatically insert API calls to the server code at build time. The model definition can also generate code from Prisma (or another database) migrations up to the frontend page.
- Finally, it includes recipes that let you install code and/or packages with one command. A solid addition to the simple
npm install
.
Tutorial: creating a Blitz.js e-commerce app
Enough of introduction, time to jump in the code and see what Blitz is really capable of.
Prerequisites
- Basic knowledge of JavaScript
- Snipcart account (free to test forever)
- Basic knowledge of Chakra UI
Installing Blitz.js
Let's start with installing the Blitz CLI.
Run: npm install -g blitz --legacy-peer-deps
or yarn global add blitz
Then run the following in your terminal to create our Blitz.js app.
blitz new blitzjs-example
cd blitzjs-example
blitz dev
To see our newly created app, head over to http://localhost:3000.
The signup and sign-in are already implemented by the default Blitz.js template. However, in this demo, we'll only be using it for admin rights.
Now here is one part of the 'magic' of Blitz.js. The website's database is already up. To open the management interface, we'll first boot it by running:
blitz prisma studio
If this spits out an error, try:
blitz prisma studio --browser none
To manage our database, simply go to http://localhost:5555.
Scaffolding the products
We'll now define the product in Blitz and ask it to generate the code.
In the command line, enter the following:
blitz generate all product price:float description:string image:string name:string
You can now look at the products folder. It should include:
- A components folder containing the product creation form.
- A mutations folder containing operations analogic to the "create", "update", and "delete" side of a CRUD API.
- A queries folder containing operations analogic to the "read" side of a CRUD API
If we look at the pages/products
folder, we can see that the new pages related to the product model operations are named according to their intended routes. Blitz.js will use this nomenclature to create the actual routes.
For this demo, we'll use the default authentication for website admin purposes.
First, let's add the admin database seed by replacing the content of db/seeds.ts
with this code:
import db from "./index"
import { SecurePassword } from "blitz"
/*
* This seed function is executed when you run `blitz db seed`.
*
*/
const admins = [
{
email: "admin@admin.com",
password: "admin12345",
},
]
const seed = async () => {
for (let i = 0; i < admins.length; i++) {
const admin = admins[i]
if (admin) {
const hashedPassword = await SecurePassword.hash(admin.password)
await db.user.create({
data: { email: admin.email, hashedPassword, role: "USER" },
select: { id: true, name: true, email: true, role: true },
})
}
}
}
export default seed
Once saved, run blitz db seed
. Then, delete all references to the signup page by deleting app\auth\pages\signup.tsx
and removing the following section in app\auth\components\LoginForm.tsx
page:
<div style={{ marginTop: "1rem" }}>
Or <Link href={Routes.SignupPage()}>Sign Up</Link>
</div>
In our first recipe, we'll add Chakra UI by using blitz install chakra-ui
. Then we'll add the chakra-ui
icons using npm install @chakra-ui/icons
.
Setting up our layout
We'll now add a menu and a footer that we will later integrate into our layout.
For this, add the file WithMenu.tsx
in app/core/components
and paste this code in it:
import {Link} from "blitz"
import {
Box,
Flex,
HStack,
IconButton,
Button,
Menu,
useDisclosure,
useColorModeValue,
Stack,
} fom '@chakra-ui/react';
import { useCurrentUser } from "app/core/hooks/useCurrentUser"
import { HamburgerIcon, CloseIcon } from '@chakra-ui/icons';
export interface MenuItem {
href: string
label: string
adminPage: boolean
}
const menuItems: Array<MenuItem> = [
{label: 'products', href:'/products', adminPage:false},
{label: 'create a product', href:'/products/new', adminPage:true},
{label: 'add an image', href:'/api/files/upload', adminPage:true}];
const NavLink = ( props: { menuItem: MenuItem}) => (
<Link href={props.menuItem.href}>
<a>{props.menuItem.label}</a>
</Link>
);
export default function WithMenu() {
const currentUser = useCurrentUser()
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<Box bg={useColorModeValue('gray.100', 'gray.900')} px={4}>
<Flex w={{md:'80%'}} m={'auto'} h={16} alignItems={'center'} justifyContent={'space-between'}>
<IconButton
size={'md'}
icon={isOpen ? <CloseIcon /> : <HamburgerIcon />}
aria-label={'Open Menu'}
display={{ md: 'none' }}
onClick={isOpen ? onClose : onOpen}
/>
<HStack spacing={8} alignItems={'center'}>
<Box><img src="blitzJsExamplelogo.png" alt="Logo" width="70px" /></Box>
<HStack
as={'nav'}
spacing={4}
display={{ base: 'none', md: 'flex' }}>
{menuItems.map((link: MenuItem) => {
if(!link.adminPage || currentUser) {
return (
<NavLink key={link.label} menuItem={link}></NavLink>
)
}
}
)}
</HStack>
</HStack>
<Flex alignItems={'center'}>
<Button className="header__checkout snipcart-checkout">
<svg width="31" height="27" viewBox="0 0 31 27" fill="none" xmlns="<http://www.w3.org/2000/svg>">
<path
d="M1.10512 0.368718C0.560256 0.368718 0.118164 0.812066 0.118164 1.35848C0.118164 1.9049 0.560256 2.34824 1.10512 2.34824H4.90887L8.30138 18.4009C8.43503 19.0053 8.83085 19.5079 9.32946 19.5041H25.7788C26.3005 19.5118 26.7799 19.0375 26.7799 18.5143C26.7799 17.9911 26.3006 17.5168 25.7788 17.5245H10.1315L9.71003 15.545H27.095C27.5371 15.5412 27.9547 15.2048 28.0511 14.7718L30.354 4.87412C30.4825 4.29933 29.9852 3.67172 29.3979 3.66786H7.21171L6.6771 1.15221C6.58329 0.71276 6.15921 0.368652 5.7107 0.368652L1.10512 0.368718ZM7.623 5.64746H12.7634L13.2569 8.61674H8.25005L7.623 5.64746ZM14.7785 5.64746H20.9881L20.4946 8.61674H15.2719L14.7785 5.64746ZM23.0031 5.64746H28.1537L27.4649 8.61674H22.5097L23.0031 5.64746ZM8.67181 10.5963H13.5862L14.0797 13.5656H9.29919L8.67181 10.5963ZM15.6009 10.5963H20.1656L19.6721 13.5656H16.0944L15.6009 10.5963ZM22.1807 10.5963H27.0023L26.3135 13.5656H21.6872L22.1807 10.5963ZM12.6197 20.164C10.8141 20.164 9.32979 21.6525 9.32979 23.4632C9.32979 25.2739 10.8141 26.7624 12.6197 26.7624C14.4252 26.7624 15.9095 25.2739 15.9095 23.4632C15.9095 21.6525 14.4252 20.164 12.6197 20.164ZM22.4892 20.164C20.6837 20.164 19.1994 21.6525 19.1994 23.4632C19.1994 25.2739 20.6837 26.7624 22.4892 26.7624C24.2948 26.7624 25.7791 25.2739 25.7791 23.4632C25.7791 21.6525 24.2948 20.164 22.4892 20.164ZM12.6197 22.1435C13.3586 22.1435 13.9356 22.7222 13.9356 23.4632C13.9356 24.2042 13.3586 24.7829 12.6197 24.7829C11.8807 24.7829 11.3037 24.2042 11.3037 23.4632C11.3037 22.7222 11.8807 22.1435 12.6197 22.1435ZM22.4892 22.1435C23.2282 22.1435 23.8052 22.7222 23.8052 23.4632C23.8052 24.2042 23.2282 24.7829 22.4892 24.7829C21.7503 24.7829 21.1733 24.2042 21.1733 23.4632C21.1733 22.7222 21.7503 22.1435 22.4892 22.1435Z"
fill="#6b7d79" className="header__checkout-fill"></path>
</svg>
</Button>
</Flex>
</Flex>
{isOpen ? (
<Box pb={4} display={{ md: 'none' }}>
<Stack as={'nav'} spacing={4}>
{menuItems.map((link) => {
if(!link.adminPage || currentUser) {
return (
<NavLink menuItem={link} key={link.label} ></NavLink>
)
}
}
)}
</Stack>
</Box>
) : null}
</Box>
</>
);
}
Now let's create our footer by adding the file WithFooter.tsx
in app/core/components
. In this file, add the following lines:
import {
Box,
Container,
Stack,
Text,
Link,
useColorModeValue,
HStack,
} from '@chakra-ui/react';
export default function WithFooter() {
return (
<Box mt={'40px'}
bg={useColorModeValue('gray.50', 'gray.900')}
color={useColorModeValue('gray.700', 'gray.200')}>
<Container
w={{md:'80%'}} m={'auto'}
as={Stack}
maxW={'6xl'}
py={4}
direction={{ base: 'column', md: 'row' }}
spacing={4}
justify={{ base: 'center', md: 'space-between' }}
align={{ base: 'center', md: 'center' }}>
<HStack spacing={8} alignItems={'center'}>
<img src="blitzJsExamplelogo.png" alt="Logo" width="55px" />
<Stack direction={'row'} spacing={6}>
<Link href={'/login'}>admin</Link>
</Stack>
</HStack>
<Text>© 2020 BlitzJs example. All rights reserved</Text>
</Container>
</Box>
);
}
Once our menu and footer components have been created, it's time to integrate them into our layout. To do so, replace the content of Layout.tsx
, located in app/core/layouts/
, with the following:
import { ReactNode } from "react"
import { Head } from "blitz"
import { Suspense } from "react"
import WithMenu from "app/core/components/WithMenu"
import WithFooter from "app/core/components/WithFooter"
import { Box } from '@chakra-ui/react';
type LayoutProps = {
title?: string
children: ReactNode
}
const Layout = ({ title, children }: LayoutProps) => {
return (
<>
<Head>
<title>{title || "blitzjs-example"}</title>
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="[<https://cdn.snipcart.com>](<https://cdn.snipcart.com/>)"></link>
<link rel="stylesheet" href="[<https://cdn.snipcart.com/themes/v3.2.1/default/snipcart.css>](<https://cdn.snipcart.com/themes/v3.2.1/default/snipcart.css>)" />
<script async src="[<https://cdn.snipcart.com/themes/v3.2.1/default/snipcart.js>](<https://cdn.snipcart.com/themes/v3.2.1/default/snipcart.js>)"></script>
</Head>
<div>
<div hidden id="snipcart" data-api-key="YOUR-API-KEY"></div>
<Suspense fallback="Loading...">
<WithMenu />
<Box w={'80%'} m={'40px auto'}>
{children}
</Box>
<WithFooter />
</Suspense>
</div>
</>
)
}
export default Layout
Make sure to replace the content of the data-api-key
attribute with your Snipcart test API key.
Creating the product creation form
To create our product creation form, we'll use our newly installed chakra-ui
package. This simple component library will help add a little bit of personality to our form layout page.
Replace the content of app/core/components/Form.tsx
with this:
import { ReactNode, PropsWithoutRef } from "react"
import { Form as FinalForm, FormProps as FinalFormProps, } from "react-final-form"
import { z } from "zod"
import { validateZodSchema } from "blitz"
import { Box, ButtonGroup, Button,ThemeProvider, theme } from "@chakra-ui/react";
export { FORM_ERROR } from "final-form"
export interface FormProps<S extends z.ZodType<any, any>>
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> {
/** All your form fields */
children?: ReactNode
/** Text to display in the submit button */
submitText?: string
schema?: S
onSubmit: FinalFormProps<z.infer<S>>["onSubmit"]
initialValues?: FinalFormProps<z.infer<S>>["initialValues"]
}
export function Form<S extends z.ZodType<any, any>>({
children,
submitText,
schema,
initialValues,
onSubmit,
...props
}: FormProps<S>) {
return (
<ThemeProvider theme={theme}>
<Box w={500} p={4} m="20px auto"
borderWidth="1px"
rounded="lg"
shadow="1px 1px 3px rgba(0,0,0,0.3)">
<FinalForm
initialValues={initialValues}
validate={validateZodSchema(schema)}
onSubmit={onSubmit}
render={({ handleSubmit, submitting, submitError }) => (
<form onSubmit={handleSubmit} className="form" {...props}>
{/* Form fields supplied as children are rendered here */}
{children}
{submitError && (
<div role="alert" style={{ color: "red" }}>
{submitError}
</div>
)}
{submitText && (
<ButtonGroup spacing={4}>
<Button
isLoading={submitting}
disabled={submitting}
loadingText="Submitting"
colorScheme="teal"
type="submit"
>
{submitText}
</Button>
</ButtonGroup>
)}
<style global jsx>{`
.form > * + * {
margin-top: 1rem;
}
`}</style>
</form>
)}
/>
</Box>
</ThemeProvider>
)
}
export default Form
Now we will customize the product form container.
For this, we'll replace the content of app/pages/products/new.tsx
with:
import { Link, useRouter, useMutation, BlitzPage, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import createProduct from "app/products/mutations/createProduct"
import { ProductForm, FORM_ERROR } from "app/products/components/ProductForm"
const NewProductPage: BlitzPage = () => {
const router = useRouter()
const [createProductMutation] = useMutation(createProduct)
return (
<div>
<ProductForm
submitText="Create Product"
onSubmit={async (values) => {
try {
const product = await createProductMutation(values)
router.push(Routes.ShowProductPage({ productId: product.id }))
} catch (error: any) {
console.error(error)
return {
[FORM_ERROR]: error.toString(),
}
}
}}
/>
</div>
)
}
NewProductPage.authenticate = true
NewProductPage.getLayout = (page) => <Layout title={"Create New Product"}>{page}</Layout>
export default NewProductPage
Then, replace the content of the actual form at app/products/components/ProductForm.tsx
by:
import { Form, FormProps } from "app/core/components/Form"
import { LabeledTextField } from "app/core/components/LabeledTextField"
import { z } from "zod"
export { FORM_ERROR } from "app/core/components/Form"
export function ProductForm<S extends z.ZodType<any, any>>(props: FormProps<S>) {
return (
<Form<S> {...props}>
<LabeledTextField name="name" label="Name" placeholder="Name" />
<LabeledTextField name="price" label="Price" placeholder="Price" type="number" />
<LabeledTextField name="description" label="Description" placeholder="Description" />
<LabeledTextField name="image" label="Image" placeholder="Image" />
</Form>
)
}
Now that the frontend side of the form is done, time to add the backend side, in this case, the createProduct
mutation file.
Replace the content of app/products/mutations/createProduct.ts
with:
import { resolver } from "blitz"
import db from "db"
import { z } from "zod"
const CreateProduct = z.object({
name: z.string(),
price: z.number(),
description: z.string(),
image: z.string(),
})
export default resolver.pipe(resolver.zod(CreateProduct), resolver.authorize(), async (input) => {
const product = await db.product.create({ data: input })
return product
})
To see the result, run blitz dev
and head on to http://localhost:3000/products/new to create a product.
Showing the products in our store
Once your products have been created, we'll add their "card" to the main page
First, add the file SplitScreenProduct.tsx
in the app/products/components
folder, and paste this code in it:
import {
Button,
Flex,
Heading,
Stack,
Text,
Image,
useBreakpointValue,
Box,
} from "@chakra-ui/react"
function ProductImage(props: { imageURL }) {
return (
<Flex flex={1} m={"auto"}>
<Image alt={"Login Image"} objectFit={"contain"} alignItems="center" src={props.imageURL} />
</Flex>
)
}
function ProductSummary(props: { product }) {
return (
<Flex m={"auto"} flex={1} align={"center"} justify={"center"}>
<Stack spacing={6} w={"full"} maxW={"lg"}>
<Heading fontSize={{ base: "3xl", md: "4xl", lg: "5xl" }}>
<Text color={"black.400"} as={"span"}>
{props.product.name}
</Text>{" "}
</Heading>
<Text fontSize={{ base: "md", lg: "lg" }} color={"gray.500"}>
{props.product.description}
</Text>
<Stack align="strech" direction={"row"} spacing={4} width={"100%"}>
<Text fontSize={{ base: "2xl", lg: "3xl" }} color={"black.400"} as={"span"}>
{props.product.price} {"$"}
</Text>
<Button
className="snipcart-add-item"
data-item-id={props.product.id}
data-item-price={props.product.price}
data-item-url={props.product.url}
data-item-description={props.product.description}
data-item-image={props.product.image}
data-item-name={props.product.name}
fontSize={{ base: "1xl", lg: "2xl" }}
flex={1}
w={"80%"}
h={"auto"}
bg={"blue.400"}
color={"white"}
_hover={{
bg: "blue.500",
}}
>
Add to cart
</Button>
</Stack>
</Stack>
</Flex>
)
}
export function SplitScreenProduct(props: { product; position }) {
return (
<Box flex="1">
<Stack display={{ base: "none", md: "flex" }} direction={{ md: "row" }}>
{props.position == "left" && <ProductImage imageURL={props.product.image} />}
<ProductSummary product={props.product} />
{props.position == "right" && <ProductImage imageURL={props.product.image} />}
</Stack>
<Stack display={{ md: "none" }} direction={{ base: "column" }}>
<ProductImage imageURL={props.product.image} />
<ProductSummary product={props.product} />
</Stack>
</Box>
)
}
Now we'll integrate that component into our main page while alternating the position of the text and picture. We will also bring our product API address into the picture. This will allow Snipcart to go to this address to verify the integrity of the products in any carts
Replace the content of app\\pages\\index.tsx
with:
import { BlitzPage, useQuery } from "blitz"
import Layout from "app/core/layouts/Layout"
import getProducts from "app/products/queries/getProducts"
import { SplitScreenProduct } from "app/products/components/SplitScreenProduct"
import { Stack } from "@chakra-ui/react"
export const ProductsList = ({ jsonProductApi }) => {
const [products] = useQuery(getProducts, null)
const productsWithJsonUrl = products.map((product) => {
return {
...product,
url: jsonProductApi,
}
})
return (
<Stack spacing={"50px"}>
{productsWithJsonUrl.map((product, index) => (
<SplitScreenProduct
key={product.id}
position={index % 2 == 0 ? "right" : "left"}
product={product}
/>
))}
</Stack>
)
}
const Home: BlitzPage<any> = ({ jsonProductApi }) => {
return (
<div className="container">
<main>
<ProductsList jsonProductApi={jsonProductApi} />
</main>
</div>
)
}
Home.suppressFirstRenderFlicker = true
Home.getLayout = (page) => <Layout title="Home">{page}</Layout>
export default Home
export async function getServerSideProps(context) {
const jsonProductApi = `${context.req.headers.host}/api/products`
return {
props: { jsonProductApi }, // will be passed to the page component as props
}
}
The default getProduct
query includes pagination, which we do not need in our single-page demo.
To fix that, replace the content of app\\products\\queries\\getProducts.ts
with:
import { paginate, resolver } from "blitz"
import db, { Prisma } from "db"
export default resolver.pipe(
resolver.authorize(),
async () =>{
const products = await db.product.findMany()
return products;
}
)
Uploading images with Blitz.js
Unfortunately, Blitz.js doesn't have an easy solution for file uploading yet, so we will have to build our own basic file uploading API. We'll also need to install the formidable package by running npm install formidable
.
In app/api
, create a files
folder and subsequently create a upload.ts
file with this content:
import { BlitzApiHandler, BlitzApiRequest, BlitzApiResponse } from "blitz"
var formidable = require('formidable');
var path = require('path');
var fs = require('fs');
const handler: BlitzApiHandler = async (req: BlitzApiRequest,
res: BlitzApiResponse) => {
if (req.method == 'POST') {
//await runMiddleware(req, res)
console.log(__dirname);
const form = new formidable.IncomingForm();
form.parse(req, function (err, fields, files) {
const oldpath = files.filetoupload.path;
const filename = files.filetoupload.name;
const newpath = path.resolve().split('.blitz')[0] + "/public/" + files.filetoupload.name;
fs.rename(oldpath, newpath, function (err) {
if (err) throw err;
res.write(`File "${filename}" uploaded successfully`);
return res.end();
});
});
} else {
res.writeHead(200, {'Content-Type': 'text/html'});
res.write('<form method="post" enctype="multipart/form-data">');
res.write('<input type="file" name="filetoupload"><br>');
res.write('<input type="submit">');
res.write('</form>');
return res.end();
}
}
export const config = {
api: {
bodyParser: false,
},
};
export default handler
That's it! You should now have a beautiful Blitz.js e-commerce app up and running.
Live demo & GitHub repo
View the Github repo here
View the live demo here
Closing thoughts
Building an app with Blitz.js was a great experience overall. Having the possibility to make an entire app using one framework without having to code a frontend layer was a game-changer.
I thought that features such as "Recipes" were time-saver (and even headache savers at times). Scaffolding based on models also made the development process fast.
I also like the flexibility of using React components, which I would typically find in Next.js.
On the other side, since Blitz.js is built on top of Next.js, which is also built on React, I found that searching for documentation could be a bit confusing at times.
Let me know in the comments what your thoughts are about Blitz and if you have tried building an app with it.
This content originally appeared on DEV Community and was authored by RealGuillaume
RealGuillaume | Sciencx (2021-10-14T19:42:34+00:00) What is Blitz.js & How to Get Started With It. Retrieved from https://www.scien.cx/2021/10/14/what-is-blitz-js-how-to-get-started-with-it/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.