Redwood and Blitz are two up-and-coming full-stack meta-frameworks that provide tooling for creating SPAs, server-side rendered pages, and statically generated content, providing a CLI to generate end-to-end scaffolds. I’ve been waiting for a worthy Rails replacement in JavaScript since who-knows-when. This article is an overview of the two, and while I’ve given more breadth to Redwood (as it differs from Rails a great deal), I personally prefer Blitz.
As the post ended up being quite lengthy, below, we provide a comparison table for the hasty ones.
A bit of history first
If you started working as a web developer in the 2010s, you might not have even heard of Ruby on Rails, even though it gave us apps like Twitter, GitHub, Urban Dictionary, Airbnb, and Shopify. Compared to the web frameworks of its time, it was a breeze to work with. Rails broke the mold of web technologies by being a highly opinionated MVC tool, emphasizing the use of well-known patterns such as convention over configuration and DRY, with the addition of a powerful CLI that created end-to-end scaffolds from model to the template to be rendered. Many other frameworks have built on its ideas, such as Django for Python, Laravel for PHP, or Sails for Node.js. Thus, arguably, it is a piece of technology just as influential as the LAMP stack before its time.
However, the fame of Ruby on Rails has faded quite a bit since its creation in 2004. By the time I started working with Node.js in 2012, the glory days of Rails were over. Twitter — built on Rails — was infamous for frequently showcasing its fail whale between 2007 and 2009. Much of it was attributed to the lack of Rails’ scalability, at least according to word of mouth in my filter bubble. This Rails bashing was further reinforced when Twitter switched to Scala, even though they did not completely ditch Ruby then.
The scalability issues of Rails (and Django, for that matter) getting louder press coverage coincided with the transformation of the Web too. More and more JavaScript ran in the browser. Webpages became highly interactive WebApps, then SPAs. Angular.js revolutionized that too when it came out in 2010. Instead of the server rendering the whole webpage by combining the template and the data, we wanted to consume APIs and handle the state changes by client-side DOM updates.
Thus, full-stack frameworks fell out of favor. Development got separated between writing back-end APIs and front-end apps. And these apps could have meant Android and iOS apps too by that time, so it all made sense to ditch the server-side rendered HTML strings and send over the data in a way that all our clients could work with.
UX patterns developed as well. It wasn’t enough anymore to validate the data on the back-end, as users need quick feedback while they’re filling out bigger and bigger forms. Thus, our life got more and more complicated: we needed to duplicate the input validations and type definitions, even if we wrote JavaScript on both sides. The latter got simpler with the more widespread (re-)adoption of monorepos, as it got somewhat easier to share code across the whole system, even if it was built as a collection of microservices. But monorepos brought their own complications, not to mention distributed systems.
And ever since 2012, I have had a feeling that whatever problem we solve generates 20 new ones. You could argue that this is called “progress”, but maybe merely out of romanticism, or longing for times past when things used to be simpler, I’ve been waiting for a “Node.js on Rails” for a while now. Meteor seemed like it could be the one, but it quickly fell out of favor, as the community mostly viewed it as something that is good for MVPs but does not scale… The Rails problem all over again, but breaking down at an earlier stage of the product lifecycle. I must admit, I never even got around to try it.
However, it seemed like we were getting there slowly but steadily. Angular 2+ embraced the code generators á la Rails, alongside with Next.js, so it seemed like it could be something similar. Next.js got API Routes, making it possible to handle the front-end with SSR and write back-end APIs too. But it still lacks a powerful CLI generator and has nothing to do with the data layer either. And in general, a good ORM was still missing from the equation to reach the power level of Rails. At least this last point seems to be solved with Prisma being around now.
Wait a minute. We have code generators, mature back-end and front-end frameworks, and finally, a good ORM. Maybe we have all pieces of the puzzle in place? Maybe. But first, let’s venture a bit further from JavaScript and see if another ecosystem has managed to further the legacy of Rails, and whether we can learn from it.
Enter Elixir and Phoenix
Elixir is a language built on Erlang’s BEAM and OTP, providing a nice concurrency model based on the actor model and processes, which also results in easy error handling due to the “let it crash” philosophy in contrast to defensive programming. It also has a nice, Ruby-inspired syntax, yet remains to be an elegant, functional language.
Phoenix is built on top of Elixir’s capabilities, first as a simple reimplementation of Rails, with a powerful code generator, an data mapping toolkit (think ORM), good conventions, and generally good dev experience, with the inbuilt scalability of the OTP.
Yeah.. So far, I wouldn’t have even raised an eyebrow. Rails got more scalable over time, and I can get most of the things I need from a framework writing JavaScript these days, even if wiring it all up is still pretty much DIY. Anyhow, if I need an interactive browser app, I’ll need to use something like React (or at least Alpine.js) to do it anyway.
Boy, you can’t even start to imagine how wrong the previous statement is. While Phoenix is a full-fledged Rails reimplementation in Elixir, it has a cherry on top: your pages can be entirely server-side rendered and interactive at the same time, using its superpower called LiveView. When you request a LiveView page, the initial state gets prerendered on the server side, and then a WebSocket connection is built. The state is stored in memory on the server, and the client sends over events. The backend updates the state, calculates the diff, and sends over a highly compressed changeset to the UI, where a client-side JS library updates the DOM accordingly.
I heavily oversimplified what Phoenix is capable of, but this section is already getting too long, so make sure to check it out yourself!
We’ve taken a detour to look at one of the best, if not the best full-stack frameworks out there. So when it comes to full-stack JavaScript frameworks, it only makes sense to achieve at least what Phoenix has achieved. Thus, what I would want to see:
- A CLI that can generate data models or schemas, along with their controllers/services and their corresponding pages
- A powerful ORM like Prisma
- Server-side rendered but interactive pages, made simple
- Cross-platform usability: make it easy for me to create pages for the browser, but I want to be able to create an API endpoint responding with JSON by just adding a single line of code.
- Bundle this whole thing together
With that said, let’s see whether Redwood or Blitz is the framework we have been waiting for.
What is RedwoodJS?
Redwood markets itself as THE full-stack framework for startups. It is THE framework everyone has been waiting for, if not the best thing since the invention of sliced bread. End of story, this blog post is over.
At least according to their tutorial.
I felt a sort of boastful overconfidence while reading the docs, which I personally find difficult to read. The fact that it takes a lighter tone compared to the usual, dry, technical texts is a welcome change. Still, as a text moves away from the safe, objective description of things, it also wanders into the territory of matching or clashing with the reader’s taste.
In my case, I admire the choice but could not enjoy the result.
Still, the tutorial is worth reading through. It is very thorough and helpful. The result is also worth the… well, whatever you feel while reading it, as Redwood is also nice to work with. Its code generator does what I would expect it to do. Actually, it does even more than I expected, as it is very handy not just for setting up the app skeleton, models, pages, and other scaffolds. It even sets your app up to be deployed to different deployment targets like AWS Lambdas, Render, Netlify, Vercel.
Speaking of the listed deployment targets, I have a feeling that Redwood pushes me a bit strongly towards serverless solutions, Render being the only one in the list where you have a constantly running service. And I like that idea too: if I have an opinionated framework, it sure can have its own opinions about how and where it wants to be deployed. As long as I’m free to disagree, of course.
But Redwood has STRONG opinions not just about the deployment, but overall on how web apps should be developed, and if you don’t agree with those, well…
I want you to use GraphQL
Let’s take a look at a freshly generated Redwood app. Redwood has its own starter kit, so we don’t need to install anything, and we can get straight to creating a skeleton.
$ yarn create redwood-app --ts ./my-redwood-app
You can omit the --ts
flag if you want to use plain JavaScript instead.
Of course, you can immediately start up the development server and see that you got a nice UI already with yarn redwood dev. One thing to notice, which is quite commendable in my opinion, is that you don’t need to globally install a redwood CLI. Instead, it always remains project local, making collaboration easier.
Now, let’s see the directory structure.
my-redwood-app
├── api/
├── scripts/
├── web/
├── graphql.config.js
├── jest.config.js
├── node_modules
├── package.json
├── prettier.config.js
├── README.md
├── redwood.toml
├── test.js
└── yarn.lock
We can see the regular prettier.config.js, jest.config.js, and there’s also a redwood.toml for configuring the port of the dev-server. We have an api and web directory for separating the front-end and the back-end into their own paths using yarn workspaces.
But wait, we have a graphql.config.js too! That’s right, with Redwood, you’ll write a GraphQL API. Under the hood, Redwood uses Apollo on the front-end and Yoga on the back-end, but most of it is made pretty easy using the CLI. However, GraphQL has its downsides, and if you’re not OK with the tradeoff, well, you’re shit out of luck with Redwood.
Let’s dive a bit deeper into the API.
my-redwood-app
├── api
│ ├── db
│ │ └── schema.prisma
│ ├── jest.config.js
│ ├── package.json
│ ├── server.config.js
│ ├── src
│ │ ├── directives
│ │ │ ├── requireAuth
│ │ │ │ ├── requireAuth.test.ts
│ │ │ │ └── requireAuth.ts
│ │ │ └── skipAuth
│ │ │ ├── skipAuth.test.ts
│ │ │ └── skipAuth.ts
│ │ ├── functions
│ │ │ └── graphql.ts
│ │ ├── graphql
│ │ ├── lib
│ │ │ ├── auth.ts
│ │ │ ├── db.ts
│ │ │ └── logger.ts
│ │ └── services
│ ├── tsconfig.json
│ └── types
│ └── graphql.d.ts
...
Here, we can see some more, backend related config files, and the debut of tsconfig.json.
- api/db/: Here resides our schema.prisma, which tells us the Redwood, of course, uses Prisma. The src/ dir stores the bulk of our logic.
- directives/: Stores our graphql schema directives.
- functions/: Here are the necessary lambda functions so we can deploy our app to a serverless cloud solution (remember STRONG opinions?).
- graphql/: Here reside our gql schemas, which can be generated automatically from our db schema.
- lib/: We can keep our more generic helper modules here.
- services/: If we generate a page, we’ll have a services/ directory, which will hold our actual business logic.
This nicely maps to a layered architecture, where the GraphQL resolvers function as our controller layer. We have our services, and we can either create a repository or dal layer on top of Prisma, or if we can keep it simple, then use it as our data access tool straight away.
So far so good. Let’s move to the front-end.
my-redwood-app
├── web
│ ├── jest.config.js
│ ├── package.json
│ ├── public
│ │ ├── favicon.png
│ │ ├── README.md
│ │ └── robots.txt
│ ├── src
│ │ ├── App.tsx
│ │ ├── components
│ │ ├── index.css
│ │ ├── index.html
│ │ ├── layouts
│ │ ├── pages
│ │ │ ├── FatalErrorPage
│ │ │ │ └── FatalErrorPage.tsx
│ │ │ └── NotFoundPage
│ │ │ └── NotFoundPage.tsx
│ │ └── Routes.tsx
│ └── tsconfig.json
...
From the config file and the package.json, we can deduce we’re in a different workspace. The directory layout and file names also show us that this is not merely a repackaged Next.js app but something completely Redwood specific.
Redwood comes with its router, which is heavily inspired by React Router. I found this a bit annoying as the dir structure-based one in Next.js feels a lot more convenient, in my opinion.
However, a downside of Redwood is that it does not support server-side rendering, only static site generation. Right, SSR is its own can of worms, and while currently you probably want to avoid it even when using Next, with the introduction of Server Components this might soon change, and it will be interesting to see how Redwood will react (pun not intended).
On the other hand, Next.js is notorious for the hacky way you need to use layouts with it (which will soon change though), while Redwood handles them as you’d expect it. In Routes.tsx, you simply need to wrap your Routes in a Set block to tell Redwood what layout you want to use for a given route, and never think about it again.
import { Router, Route, Set } from "@redwoodjs/router";
import BlogLayout from "src/layouts/BlogLayout/";
const Routes = () => {
return (
<Router>
<Route path="/login" page={LoginPage} name="login" />
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
);
};
export default Routes;
Notice that you don’t need to import the page components, as it is handled automatically. Why can’t we also auto-import the layouts though, as for example Nuxt 3 would? Beats me.
Another thing to note is the /article/{id:Int}
part. Gone are the days when you always need to make sure to convert your integer ids if you get them from a path variable, as Redwood can convert them automatically for you, given you provide the necessary type hint.
Now’s a good time to take a look at SSG. The NotFoundPage probably doesn’t have any dynamic content, so we can generate it statically. Just add prerender, and you’re good.
const Routes = () => {
return (
<Router>
...
<Route notfound page={NotFoundPage} prerender />
</Router>
);
};
export default Routes;
You can also tell Redwood that some of your pages require authentication. Unauthenticated users should be redirected if they try to request it.
import { Private, Router, Route, Set } from "@redwoodjs/router";
import BlogLayout from "src/layouts/BlogLayout/";
const Routes = () => {
return (
<Router>
<Route path="/login" page={LoginPage} name="login" />
<Private unauthenticated="login">
<Set wrap={PostsLayout}>
<Route
path="/admin/posts/new"
page={PostNewPostPage}
name="newPost"
/>
<Route
path="/admin/posts/{id:Int}/edit"
page={PostEditPostPage}
name="editPost"
/>
</Set>
</Private>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
);
};
export default Routes;
Of course, you need to protect your mutations and queries, too. So make sure to append them with the pre-generated @requireAuth.
Another nice thing in Redwood is that you might not want to use a local auth strategy but rather outsource the problem of user management to an authentication provider, like Auth0 or Netlify-Identity. Redwood’s CLI can install the necessary packages and generate the required boilerplate automatically.
What looks strange, however, at least with local auth, is that the client makes several roundtrips to the server to get the token. More specifically, the server will be hit for each currentUser or isAuthenticated call.
Frontend goodies in Redwood
There are two things that I really loved about working with Redwood: Cells and Forms.
A cell is a component that fetches and manages its own data and state. You define the queries and mutations it will use, and then export a function for rendering the Loading, Empty, Failure, and Success states of the component. Of course, you can use the generator to create the necessary boilerplate for you.
A generated cell looks like this:
import type { ArticlesQuery } from "types/graphql";
import type { CellSuccessProps, CellFailureProps } from "@redwoodjs/web";
export const QUERY = gql`
query ArticlesQuery {
articles {
id
}
}
`;
export const Loading = () => <div>Loading...</div>;
export const Empty = () => <div>Empty</div>;
export const Failure = ({ error }: CellFailureProps) => (
<div style={{ color: "red" }}>Error: {error.message}</div>
);
export const Success = ({ articles }: CellSuccessProps<ArticlesQuery>) => {
return (
<ul>
{articles.map((item) => {
return <li key={item.id}>{JSON.stringify(item)}</li>;
})}
</ul>
);
};
Then you just import and use it as you would any other component, for example, on a page.
import ArticlesCell from "src/components/ArticlesCell";
const HomePage = () => {
return (
<>
<MetaTags title="Home" description="Home page" />
<ArticlesCell />
</>
);
};
export default HomePage;
However! If you use SSG on pages with cells — or any dynamic content really —only their loading state will get pre-rendered, which is not much of a help. That’s right, no getStaticProps for you if you go with Redwood.
The other somewhat nice thing about Redwood is the way it eases form handling, though the way they frame it leaves a bit of a bad taste in my mouth. But first, the pretty part.
import { Form, FieldError, Label, TextField } from "@redwoodjs/forms";
const ContactPage = () => {
return (
<>
<Form config={{ mode: "onBlur" }}>
<Label name="email" errorClassName="error">
Email
</Label>
<TextField
name="email"
validation={{
required: true,
pattern: {
value: /^[^@]+@[^.]+\..+$/,
message: "Please enter a valid email address",
},
}}
errorClassName="error"
/>
<FieldError name="email" className="error" />
</Form>
</>
);
};
The TextField
components validation attribute expects an object to be passed, with a pattern against which the provided input value can be validated.
The errorClassName
makes it easy to set the style of the text field and its label in case the validation fails, e.g. turning it red. The validations message will be printed in the FieldError
component. Finally, the config={{ mode: 'onBlur' }}
tells the form to validate each field when the user leaves them.
The only thing that spoils the joy is the fact that this pattern is eerily similar to the one provided by Phoenix. Don’t get me wrong. It is perfectly fine, even virtuous, to copy what’s good in other frameworks. But I got used to paying homage when it’s due. Of course, it’s totally possible that the author of the tutorial did not know about the source of inspiration for this pattern. If that’s the case, let me know, and I’m happy to open a pull request to the docs, adding that short little sentence of courtesy.
But let’s continue and take a look at the whole working form.
import { MetaTags, useMutation } from "@redwoodjs/web";
import { toast, Toaster } from "@redwoodjs/web/toast";
import {
FieldError,
Form,
FormError,
Label,
Submit,
SubmitHandler,
TextAreaField,
TextField,
useForm,
} from "@redwoodjs/forms";
import {
CreateContactMutation,
CreateContactMutationVariables,
} from "types/graphql";
const CREATE_CONTACT = gql`
mutation CreateContactMutation($input: CreateContactInput!) {
createContact(input: $input) {
id
}
}
`;
interface FormValues {
name: string;
email: string;
message: string;
}
const ContactPage = () => {
const formMethods = useForm();
const [create, { loading, error }] = useMutation<
CreateContactMutation,
CreateContactMutationVariables
>(CREATE_CONTACT, {
onCompleted: () => {
toast.success("Thank you for your submission!");
formMethods.reset();
},
});
const onSubmit: SubmitHandler<FormValues> = (data) => {
create({ variables: { input: data } });
};
return (
<>
<MetaTags title="Contact" description="Contact page" />
<Toaster />
<Form
onSubmit={onSubmit}
config={{ mode: "onBlur" }}
error={error}
formMethods={formMethods}
>
<FormError error={error} wrapperClassName="form-error" />
<Label name="email" errorClassName="error">
Email
</Label>
<TextField
name="email"
validation={{
required: true,
pattern: {
value: /^[^@]+@[^.]+\..+$/,
message: "Please enter a valid email address",
},
}}
errorClassName="error"
/>
<FieldError name="email" className="error" />
<Submit disabled={loading}>Save</Submit>
</Form>
</>
);
};
export default ContactPage;
Yeah, that’s quite a mouthful. But this whole thing is necessary if we want to properly handle submissions and errors returned from the server. We won’t dive deeper into it now, but if you’re interested, make sure to take a look at Redwood’s really nicely written and thorough tutorial.
Now compare this with how it would look like in Phoenix LiveView.
<div>
<.form
let={f}
for={@changeset}
id="contact-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save">
<%= label f, :title %>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<div>
<button type="submit" phx-disable-with="Saving...">Save</button>
</div>
</.form>
</div>
A lot easier to see through while providing almost the same functionality. Yes, you’d be right to call me out for comparing apples to oranges. One is a template language, while the other is JSX. Much of the logic in a LiveView happens in an elixir file instead of the template, while JSX is all about combining the logic with the view. However, I’d argue that an ideal full-stack framework should allow me to write the validation code once for inputs, then let me simply provide the slots in the view to insert the error messages into, and allow me to set up the conditional styles for invalid inputs and be done with it. This would provide a way to write cleaner code on the front-end, even when using JSX. You could say this is against the original philosophy of React, and my argument merely shows I have a beef with it. And you’d probably be right to do so. But this is an opinion article about opinionated frameworks, after all, so that’s that.
The people behind RedwoodJS
Credit, where credit is due.
Redwood was created by GitHub co-founder and former CEO Tom Preston-Werner, Peter Pistorius, David Price & Rob Cameron. Moreover, its core team currently consists of 23 people. So if you’re afraid to try out newish tools because you may never know when their sole maintainer gets tired of the struggles of working on a FOSS tool in their free time, you can rest assured: Redwood is here to stay.
Redwood: Honorable mentions
Redwood
- also comes bundled with Storybook,
- provides the must-have graphiql-like GraphQL Playground,
- provides accessibility features out of the box like the RouteAnnouncemnet SkipNavLink, SkipNavContent and RouteFocus components,
- of course it automatically splits your code by pages.
The last one is somewhat expected in 2022, while the accessibility features would deserve their own post in general. Still, this one is getting too long already, and we haven’t even mentioned the other contender yet.
Let’s see BlitzJS
Blitz is built on top of Next.js, and it is inspired by Ruby on Rails and provides a “Zero-API” data layer abstraction. No GraphQL, pays homage to predecessors… seems like we’re off to a good start. But does it live up to my high hopes? Sort of.
A troubled past
Compared to Redwood, Blitz’s tutorial and documentation are a lot less thorough and polished. It also lacks several convenience features:
- It does not really autogenerate host-specific config files.
- Blitz cannot run a simple CLI command to set up auth providers.
- It does not provide accessibility helpers.
- Its code generator does not take into account the model when generating pages.
Blitz’s initial commit was made in February 2020, a bit more than half a year after Redwood’s in June 2019, and while Redwood has a sizable number of contributors, Blitz’s core team consists of merely 2-4 people. In light of all this, I think they deserve praise for their work.
But that’s not all. If you open up their docs, you’ll be greeted with a banner on top announcing a pivot.
While Blitz originally included Next.js and was built around it, Brandon Bayer and the other developers felt it was too limiting. Thus they forked it, which turned out to be a pretty misguided decision. It quickly became obvious that maintaining the fork would take a lot more effort than the team could invest.
All is not lost, however. The pivot aims to turn the initial value proposition “JavaScript on Rails with Next” into “JavaScript on Rails, bring your own Front-end Framework”.
And I can’t tell you how relieved I am that this recreation of Rails won’t force me to use React.
Don’t get me wrong. I love the inventiveness that React brought to the table. Front-end development has come a long way in the last nine years, thanks to React. Other frameworks like Vue and Svelte might lack behind in following the new concepts, but this also means they have more time to polish those ideas even further and provide better DevX. Or at least I find them a lot easier to work with without ever being afraid that my client-side code’s performance would grind to a standstill.
All in all, I find this turn of events a lucky blunder.
How to create a Blitz app
You’ll need to install Blitz globally (run yarn global add blitz or npm install -g blitz –legacy-peer-deps), before you create a Blitz app. That’s possibly my main woe when it comes to Blitz’s design, as this way, you cannot lock your project across all contributors to use a given Blitz CLI version and increment it when you see fit, as Blitz will automatically update itself from time to time.
Once blitz is installed, run
$ blitz new my-blitz-app
It will ask you
- whether you want to use TS or JS,
- if it should include a DB and Auth template (more on that later),
- if you want to use npm, yarn or pnpm to install dependencies,
- and if you want to use React Final Form or React Hook Form.
Once you have answered all its questions, the CLI starts to download half of the internet, as it is customary. Grab something to drink, have a lunch, finish your workout session, or whatever you do to pass the time and when you’re done, you can fire up the server by running
$ blitz dev
And, of course, you’ll see the app running and the UI telling you to run
$ blitz generate all project name:string
But before we do that, let’s look around in the project directory.
my-blitz-app/
├── app/
├── db/
├── mailers/
├── node_modules/
├── public/
├── test/
├── integrations/
├── babel.config.js
├── blitz.config.ts
├── blitz-env.d.ts
├── jest.config.ts
├── package.json
├── README.md
├── tsconfig.json
├── types.ts
└── yarn.lock
Again, we can see the usual suspects: config files, node_modules, test, and the likes. The public directory — to no one’s surprise — is the place where you store your static assets. Test holds your test setup and utils. Integrations is for configuring your external services, like a payment provider or a mailer. Speaking of the mailer, that is where you can handle your mail-sending logic. Blitz generates a nice template with informative comments for you to get started, including a forgotten password email template.
As you’d probably guessed, the app and db directories are the ones where you have the bulk of your app-related code. Now’s the time to do as the generated landing page says and run blitz generate all project name:string.
Say yes, when it asks you if you want to migrate your database and give it a descriptive name like add project.
Now let’s look at the db directory.
my-blitz-app/
└── db/
├── db.sqlite
├── db.sqlite-journal
├── index.ts
├── migrations/
│ ├── 20220610075814_initial_migration/
│ │ └── migration.sql
│ ├── 20220610092949_add_project/
│ │ └── migration.sql
│ └── migration_lock.toml
├── schema.prisma
└── seeds.ts
The migrations directory is handled by Prisma, so it won’t surprise you if you’re already familiar with it. If not, I highly suggest trying it out on its own before you jump into using either Blitz or Redwood, as they heavily and transparently rely on it.
Just like in Redwood’s db dir, we have our schema.prisma, and our sqlite db, so we have something to start out with. But we also have a seeds.ts and index.ts. If you take a look at the index.ts file, it merely re-exports Prisma with some enhancements, while the seeds.ts file kind of speaks for itself.
Now’s the time to take a closer look at our schema.prisma.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
// --------------------------------------
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String?
email String @unique
hashedPassword String?
role String @default("USER")
tokens Token[]
sessions Session[]
}
model Session {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
handle String @unique
hashedSessionToken String?
antiCSRFToken String?
publicData String?
privateData String?
user User? @relation(fields: [userId], references: [id])
userId Int?
}
model Token {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
hashedToken String
type String
// See note below about TokenType enum
// type TokenType
expiresAt DateTime
sentTo String
user User @relation(fields: [userId], references: [id])
userId Int
@@unique([hashedToken, type])
}
// NOTE: It's highly recommended to use an enum for the token type
// but enums only work in Postgres.
// See: https://blitzjs.com/docs/database-overview#switch-to-postgre-sql
// enum TokenType {
// RESET_PASSWORD
// }
model Project {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
}
As you can see, Blitz starts out with models to be used with a fully functional User management. Of course, it also provides all the necessary code in the app scaffold, meaning that the least amount of logic is abstracted away, and you are free to modify it as you see fit.
Below all the user-related models, we can see the Project model we created with the CLI, with an automatically added id, createdAt, and updatedAt files. One of the things that I prefer in Blitz over Redwood is that its CLI mimics Phoenix, and you can really create everything from the command line end-to-end.
This really makes it easy to move quickly, as less context switching happens between the code and the command line. Well, it would if it actually worked, as while you can generate the schema properly, the generated pages, mutations, and queries always use name: string, and disregard the entity type defined by the schema, unlike Redwood. There’s already an open pull request to fix this, but the Blitz team understandably has been focusing on getting v2.0 done instead of patching up the current stable branch.
That’s it for the db, let’s move on to the app directory.
my-blitz-app
└── app
├── api/
├── auth/
├── core/
├── pages/
├── projects/
└── users/
The core directory contains Blitz goodies, like a predefined and parameterized Form (without Redwood’s or Phoenix’s niceties though), a useCurrentUser hook, and a Layouts directory, as Bliz made it easy to persist layouts between pages, which will be rendered completely unnecessary with the upcoming Next.js Layouts. This reinforces further that the decision to ditch the fork and pivot to a toolkit was probably a difficult but necessary decision.
The auth directory contains the fully functional authentication logic we talked about earlier, with all the necessary database mutations such as signup, login, logout, and forgotten password, with their corresponding pages and a signup and login form component. The getCurrentUser query got its own place in the users directory all by itself, which makes perfect sense.
And we got to the pages and projects directories, where all the action happens.
Blitz creates a directory to store database queries, mutations, input validations (using zod), and model-specific components like create and update forms in one place. You will need to fiddle around in these a lot, as you will need to update them according to your actual model. This is nicely laid out though in the tutorial… Be sure to read it, unlike I did when I first tried Blitz out.
my-blitz-app/
└── app/
└── projects/
├── components/
│ └── ProjectForm.tsx
├── mutations/
│ ├── createProject.ts
│ ├── deleteProject.ts
│ └── updateProject.ts
└── queries/
├── getProjects.ts
└── getProject.ts
Whereas the pages directory won’t be of any surprise if you’re already familiar with Next.
my-blitz-app/
└── app/
└── pages/
├── projects/
│ ├── index.tsx
│ ├── new.tsx
│ ├── [projectId]/
│ │ └── edit.tsx
│ └── [projectId].tsx
├── 404.tsx
├── _app.tsx
├── _document.tsx
├── index.test.tsx
└── index.tsx
A bit of explanation if you haven’t tried Next out yet: Blitz uses file-system-based routing just like Next. The pages directory is your root, and the index file is rendered when the path corresponding to a given directory is accessed. Thus when the root path is requested, pages/index.tsx
will be rendered, accessing /projects
will render pages/projects/index.tsx
, /projects/new
will render pages/projects/new.tsx
and so on.
If a filename is enclosed in []-s, it means that it corresponds to a route param. Thus /projects/15
will render pages/projects/[projectId].tsx
. Unlike in Next, you access the param’s value within the page using the <code>useParam(name: string, type?: string)</code> hook. To access the query object, use the <code>useRouterQuery(name: string)</code>. To be honest, I never really understood why Next needs to mesh together the two.
When you generate pages using the CLI, all pages are protected by default. To make them public, simply delete the [PageComponent].authenticate = true
line. This will throw an AuthenticationError
if the user is not logged in anyway, so if you’d rather redirect unauthenticated users to your login page, you probably want to use [PageComponent].authenticate = {redirectTo: '/login'}
.
In your queries and mutations, you can use the ctx context arguments value to call ctx.session.$authorize or resolver.authorize in a pipeline to secure your data.
Finally, if you still need a proper http API, you can create Express-style handler functions, using the same file-system routing as for your pages.
A possible bright future
While Blitz had a troubled past, it might have a bright future. It is still definitely in the making and not ready for widespread adoption. The idea of creating a framework agnostic full-stack JavaScript toolkit is a versatile concept. This strong concept is further reinforced by the good starting point, which is the current stable version of Blitz. I’m looking further to see how the toolkit will evolve over time.
Redwood vs. Blitz: Comparison and Conclusion
I set out to see whether we have a Rails, or even better, Phoenix equivalent in JavaScript. Let’s see how they measured up.
1. CLI code generator
Redwood’s CLI gets the checkmark on this one, as it is versatile, and does what it needs to do. The only small drawback is that the model has to be written in file first, and cannot be generated.
Blitz’s CLI is still in the making, but that’s true about Blitz in general, so it’s not fair to judge it by what’s ready, but only by what it will be. In that sense, Blitz would win if it was fully functional (or will when it will be), as it can really generate pages end-to-end.
Verdict: Tie
2. A powerful ORM
That’s a short one. Both use Prisma, which is a powerful enough ORM.
Verdict: Tie
3. Server side rendered but interactive pages
Well, in today’s ecosystem, that might be wishful thinking. Even in Next, SSR is something you should avoid, at least until we’ll have Server Components in React.
But which one mimics this behavior the best?
Redwood does not try to look like a Rails replacement. It has clear boundaries demarcated by yarn workspaces between front-end and back-end . It definitely provides nice conventions and — to keep it charitable — nicely reinvented the right parts of Phoenix’s form handling. However, strictly relying on GraphQL feels a bit overkill. For small apps that we start out with anyway when opting to use a full-stack framework, it definitely feels awkward.
Redwood is also React exclusive, so if you prefer using Vue, Svelte or Solid, then you have to wait until someone reimplements Redwood for your favorite framework.
Blitz follows the Rails way, but the controller layer is a bit more abstract. This is understandable, though, as using Next’s file-system-based routing, a lot of things that made sense for Rails do not make sense for Blitz. And in general, it feels more natural than using GraphQL for everything. In the meantime, becoming framework agnostic makes it even more versatile than Redwood.
Moreover, Blitz is on its way to becoming framework agnostic, so even if you’d never touch React, you’ll probably be able to see its benefits in the near future.
But to honor the original criterion: Redwood provides client-side rendering and SSG (kind of), while Blitz provides SSR on top of the previous two.
Verdict: Die-hard GraphQL fans will probably want to stick with Redwood. But according to my criteria, Blitz hands down wins this one.
4. API
Blitz auto generates an API for data access that you can use if you want to, but you can explicitly write handler functions too. A little bit awkward, but the possibility is there.
Redwood maintains a hard separation between front-end and back-end, so it is trivial that you have an API, to begin with. Even if it’s a GraphQL API, that might just be way too much to engineer for your needs.
Verdict: Tie (TBH, I feel like they both suck at this the same amount.)
Bye now!
In summary, Redwood is a production-ready, React+GraphQL-based full-stack JavaScript framework made for the edge. It does not follow the patterns laid down by Rails at all, except for being highly opinionated. It is a great tool to use if you share its sentiment, but my opinion greatly differs from Redwood’s on what makes development effective and enjoyable.
Blitz, on the other hand, follows in the footsteps of Rails and Next, and is becoming a framework agnostic, full-stack toolkit that eliminates the need for an API layer.
I hope you found this comparison helpful. Leave a comment if you agree with my conclusion and share my love for Blitz. If you don’t, argue with the enlightened ones… they say controversy boosts visitor numbers.