Sample TodoApp in Fresh Deno framework
Marco Iamonte
Posted on July 3, 2022
New day and, yet, another framework. Yes, that's exactly how it has been for a few years, so why not trying it out?
Recently, the fresh framework came out and I've seen a whole lot of articles, drama and excitement about it, which made me wondering "fine, what's new about this?". So, I've decided to head to the homepage and find out what the hype is for, what's the good and what is (of course) the "bad".
Heading to the website, you will immediately notice the awesome landing with the drop animation, which is a great start.
Then, reading the core points, you will guess that the framework seems to be trying to take place where other bigger frameworks might fall: simplicity, clever SSR, reduced tooling and less overhead.
Most of these points are something that usually triggers most of the frontend and full stack developers that needs to deliver performant, scalable, efficient and maintainable solutions: most of the times you spend most of the time dealing with node_modules
, making someone else able to compile your project because for unknown reasons he is using an older version of node (or a newer one), end up having your local build running but failing on the CI server for unknown reasons, needing to be able to customise the head tags for the SEO and other subtle things that makes the whole experience a bit less appealing that it should be.
The goal of fresh, to me, seems to be reducing or removing the above steps, and it actually does it by:
1) Not using node (it uses Deno instead).
2) Gain appeal because it uses and supports preact out of box.
3) Remove the build step.
These three points are, in my opinion, the three reasons you might want to choose this framework over any other alternative (like nextjs, nuxtjs or whatever other one you like): it's simple, it handles basic features well, it's easy to use and reduces to the minimum the amount of javascript on the client side.
I've tried the fresh framework yesterday and actually tried to replicate a project that was previously made in Vue and, although I had some small difficulties, the final project came out pretty well and the performances are actually admirable, though I do have some regrets of choosing this over nextjs which was, in this case, probably more suitable.
Because every single time I see a tech news I usually see someone trying to run Doom on something new, I usually feel like for web devs the Doom equivalent is the Todo app. So, why not making one in Fresh and share the result? But hey, everyone would be able to do a Todo app, so why not adding complexity to it? Therefore, I came up with this idea: I want to create a fresh project that...:
- Shows how island works, meaning that some part of the project must use islands.
- Shows how things can be accomplished without Islands (and where).
- Shows how even an API can be developed with fresh.
- Deploy the application using Docker instead of the default way proposed by the fresh (using Deno deploy).
So, in the next chapters, we will create an application that aims to show the core features of Fresh, with an extra attention on the backend layer, trying to focus on the core layer of the application, the data layer of the application and, finally, the application layer, by still keeping the opinionated structure created by the fresh authors.
The next chapters assumes Deno is already installed in your environment.
The final result will look like this:
The entire code that is explained and provided in this post can be found here: https://github.com/briosheje/Fresh-Deno-Mongo-Docker-Todoapp
Chapter 1: scaffolding a project
To scaffold a project, simply run deno run -A -r https://fresh.deno.dev my-app
.
In our case, since we already created a repository and already are in the target folder, we will rather run: deno run -A -r https://fresh.deno.dev .
.
specifies to create the project in the current folder.
You might see this message: The target directory is not empty (files could get overwritten). Do you want to continue anyway? [y/N]
Hit yes in case you have nothing you need in the folder, otherwise move what you need and run the command again: the folder where you create a new project must be empty.
For the UI layer, we will be using tailwind as it is shipped out with Fresh and as far as I could see, it's the only feasible solution right now, so when the prompt ask this:
Do you want to use 'twind' (https://twind.dev/) for styling? [y/N]
Answer y
.
If you inspect the created folders and data (using, for example ls
in MacOS) you will see that the core project has been created and that the core dependencies have been downloaded.
The project created will have the following folders:
- routes: This is where routing happens and is handled. If you come from nextjs or similar frameworks, this will be extremely familiar: each file matches a route (or a pattern), each folder matches a sub route. Routes can be handler, allowing you to handle the verb and what the route actually do and might render something or return a simple response. More on this can be directly checked on the documentation, I won't focus much on this.
-
static: This is where static assets are placed. Static assets are resolved using
/
in html templates and css files and caching can be handled usingasset
(read more about caching here). - utils: This folder contains various stuff, right now out of box it comes with a tailwind wrapper for the final application.
- islands: This is the most unique folder of the framework, since it contains preact components that will be hydrated. This folder cannot have subfolders and preact components cannot have complex props like Functions, Date instances and anything which isn't a primitive or something JSON-serializable.
To run the project and check everything is working, run deno task start
, which will be running the start
task in the deno.json
file, which basically runs the dev.ts
file and watches for changes on static
and routes
.
Also, side note but in my opinion relevant note, fresh seems to prefer the import_map
approach with Deno, meaning that dependencies are mapped in a file and referred in code through dependency names rather than the URL to the dependency, so we will follow the preferred approach in the next chapters.
If everything is running properly, we can procede with the next chapter: preparing to work with Docker.
Chapter 2: MongoDB and Docker for the dev environment.
Because the fresh framework is agnostic about this, I will be doing this in my way, which is based on my experience: in our case, we will surely need a database. Whichever the database is (spoiler: it will be MongoDB), I usually like to virtualise everything where possible, but I also like to keep multiple ways of accomplishing the same task for my collaborators, so we will set up this project with:
- A
docker-compose
for development where the entire folder is mounted and Deno self-refreshes upon changes. - A
docker-compose
for production use where the environment is used to pass secrets to the application.
For both environments, the application will need to know where the database is, so we are going to use environment
variables to do so, therefore we will install a dependency called dotenv by adding it to the import_map.json
file: in case you are using visual studio code to develop, remember to cache the dependency.
Since we will be using mongo, we will also install mongo.
Our import_map.json
file will then look like this:
{
"imports": {
"mongo": "https://deno.land/x/mongo@v0.30.1/mod.ts",
"dotenv": "https://deno.land/x/dotenv@v3.2.0/mod.ts",
"$fresh/": "https://deno.land/x/fresh@1.0.0/",
"preact": "https://esm.sh/preact@10.8.1",
"preact/": "https://esm.sh/preact@10.8.1/",
"preact-render-to-string": "https://esm.sh/preact-render-to-string@5.2.0?deps=preact@10.8.1",
"@twind": "./utils/twind.ts",
"twind": "https://esm.sh/twind@0.16.17",
"twind/": "https://esm.sh/twind@0.16.17/"
}
}
Now that the dependencies are setup, let's procede by creating an environment file, adding it to .gitignore and creating a .env.example file to guide future users through the creation of the initial .env file.
The .env file will be structured like this:
MONGODB_URI=
Where the value of the environment variable will point to the desired mongodb instance.
Our .env
file will also be ignored since it will only be used in development, so let's add this to the .gitignore
(or create one if it does not exist yet):
.env
Once this is done, we will create a new folder where the core of our backend lives in, and we will be naming the folder core
. Inside the core
folder, we will create a file called app-env.ts
where we will handle the environment and export variables:
import { config as dotEnvConfig } from "dotenv";
dotEnvConfig({
export: true,
});
const appEnv = {
MONGODB_URI: Deno.env.get("MONGODB_URI"),
};
const everyEnvVariableFilled = Object.values(appEnv).every(
(v) => v !== null && v !== undefined && v !== "" && !Number.isNaN(v)
);
if (!everyEnvVariableFilled) {
console.error(
`Not all env variables are correctly compiled, please check that each env variable has a value.`
);
Deno.exit(1);
}
export default appEnv;
In this way, the process will gracefully exit with an error if an environment variable is missing and needed to start the application.
In the core
folder, we will create a data
folder where we will be exposing the mongodb client. In a better architecture the data layer would be separated from the application layer, but because we are just making an example app I'm not going to separate this layer now.
We will create the following structure:
- /data/models will hold the mongo/application models (in this case, these will be shared).
- /data/mongo-client.ts will export the client.
- /data/collections.ts will be an enum with the supported collections (currently one)
- /data/[entity] will hold utility functions for the specified database entity, like create, get, list and other actions.
mongo-client.ts
:
import { MongoClient } from "mongo";
import appEnv from "../app-env.ts";
const { MONGODB_URI } = appEnv;
const client = new MongoClient();
if (!MONGODB_URI) {
console.error(`MONGODB Uri missing. Exiting.`);
Deno.exit(1);
}
await client.connect(MONGODB_URI);
const database = client.database("TODO_APP");
export default database;
The above file will use the environment to know where to connect to.
collections.ts
:
export enum Collections {
TODOS = "todos",
}
models/todo.ts
:
import { ObjectId } from "mongo";
export interface Todo {
_id: ObjectId;
name: string;
done: boolean;
}
export type NewTodo = Omit<Todo, "_id">;
export function isNewTodo(item: any): item is NewTodo {
return Boolean(item?.name) && !item._id;
}
Please note that we will also be providing utility methods like the type guard isNewTodo
in the models, these will be needed later.
todo/create-todo.ts
import { Collections } from "../collections.ts";
import { Todo, NewTodo } from "../models/todo.ts";
import client from "../mongo-client.ts";
export default function createTodo(todo: NewTodo) {
return client.collection<Todo>(Collections.TODOS).insertOne(todo);
}
todo/get-todo.ts
import { Collections } from "../collections.ts";
import { ObjectId } from "mongo";
import { Todo } from "../models/todo.ts";
import client from "../mongo-client.ts";
export default function getTodo(id: string) {
return client.collection<Todo>(Collections.TODOS).findOne({
_id: new ObjectId(id),
});
}
todo/list-todo.ts
import { Collections } from "../collections.ts";
import { Todo } from "../models/todo.ts";
import client from "../mongo-client.ts";
export default function listAllTodos() {
return client.collection<Todo>(Collections.TODOS).find().toArray();
}
todo/toggle-todo-done.ts
import { ObjectId } from "mongo";
import { Collections } from "../collections.ts";
import { Todo } from "../models/todo.ts";
import client from "../mongo-client.ts";
import getTodo from "./get-todo.ts";
export default async function toggleTodoDone(todoId: string) {
const collection = client.collection<Todo>(Collections.TODOS);
const todoDoc = await getTodo(todoId);
if (!todoDoc) {
throw new Error(`Record with id ${todoId} was not found.`);
}
return collection.updateOne(
{
_id: new ObjectId(todoId),
},
{
$set: {
...todoDoc,
done: !todoDoc.done,
},
}
);
}
Please note that all the above methods were wrote down just to test the framework, these should be written better in a production-ready environment.
This data layer will allow us to later retrieve and alter data from the database, but because we actually need a database, we will take advantage of docker and the docker environment plus Makefile commands to create routines that allow us to test everything locally.
Because we are planning to support at least two environments, we will be shipping two docker-compose files: although the production environment might not use docker-compose, we will still create a sample one for production.
In our dev docker-compose, we want to have a stack that includes mongo and a UI to manage the database. I wish I could also mount Deno and use it with Fresh, but it looks like it currently does not work, so our docker-compose-dev.yml
will look like this:
version: "3.4"
services:
todo-mongo-dev:
image: mongo:latest
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- 27017:27017
networks:
- todo-app-dev-network
mongo-express:
image: mongo-express:latest
restart: always
ports:
- 8081:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example
ME_CONFIG_MONGODB_URL: mongodb://root:example@todo-mongo-dev:27017/
networks:
- todo-app-dev-network
networks:
todo-app-dev-network:
Because we are using this only in localhost, we don't care about any environment variable and any secure credential: this stack will be only used in development mode.
Next step is configuring our Deno application to know what is the url to connect for the mongo database, and this is where the .env
file we have created before will be needed: in our .env file we will define the mongodb url so that our application can connect to the database, so our .env file will now become this:
MONGODB_URI=mongodb://root:example@127.0.0.1:27017/
In this way, when the Deno application runs, dotenv
will load the environment variables from the .env
file directly.
To run the stack, we will create the following Makefile
and run the run-dev
command:
run-dev:
docker-compose -f ./docker-compose-dev.yml down
docker-compose -f ./docker-compose-dev.yml rm
docker-compose -f ./docker-compose-dev.yml build
docker-compose -f ./docker-compose-dev.yml up -d --remove-orphans
# Sleep below is needed to wait for mongo to start, otherwise we would need
# to handle that in code.
sleep 5
deno task start
dev-down:
docker-compose -f ./docker-compose-dev.yml down
docker-compose -f ./docker-compose-dev.yml rm
Running make run-dev
should take up the stack (or down in case it is up and then up again), should wait 5 seconds (which should be enough for mongo to start listening) and then will run deno task start
to run the stack that will load the .env file and connect to the database.
We now should be good to go with the next chapter: wiring the client and the server.
Chapter 3: wiring client and server
Now that we have a database inside our stack and we have our data layer done, we can focus on the frontend and the backend: because this is a demo, the core idea is to show the features of the framework, therefore we are going to implement the following:
1) Form testing: use a POST form submit to the same page to create a new Todo record.
2) API testing: make an api endpoint like /api/v1/todo/[id]/toggleState
with PUT verb to toggle the state of a todo. In this case, a page refresh should not occur.
Before doing both, however, we will need to first display the current todos, so our "index" will first need to do so:
/routes/index.tsx
/** @jsx h */
import { h } from "preact";
import { tw } from "@twind";
import { Handlers, HandlerContext, PageProps } from "$fresh/server.ts";
import { Todo } from "../core/data/models/todo.ts";
import listAllTodos from "../core/data/todo/list-todo.ts";
type TodosProps = {
allTodos: Todo[];
};
export const handler: Handlers<TodosProps> = {
async GET(_req, ctx) {
const allTodos = await listAllTodos();
return ctx.render({
allTodos: allTodos ?? [],
});
},
}
export default function Todos(props: PageProps<TodosProps>) {
const { data } = props;
const { allTodos } = data;
return (
<div
className={tw`h-100 w-full flex flex-col items-center justify-center bg-teal-lightest font-sans`}
>
<div
className={tw`bg-white rounded shadow p-6 m-4 w-full lg:w-3/4 lg:max-w-lg`}
>
<h2>Todo Items - Items uses the API endpoints to update data.</h2>
<hr className={tw`mb-1`} />
{allTodos.map((todo) => (
<span>TODO todo island component</span>
))}
</div>
</div>
);
}
The above code will handle the GET
request, fetch all the current todos (beware: a distributed cache here would be awesome) and render the page by injecting in a react-way the current todos. Everything you see above happens at server side, no javascript will be injected to the client side and the template is compiled at server side.
Once the todos are ready, we then need to populate these, so we need to:
- Create a form
- Find a way to handle the form submit on the same page
To accomplish so, we will start with a simple form, which will be placed under our Todo list:
<hr />
<div
className={tw`bg-white rounded shadow p-6 m-4 w-full lg:w-3/4 lg:max-w-lg`}
>
<h2>
Create a new todo: this approach uses a SUBMIT action against the same
page.
</h2>
<form method="POST" className={tw`flex flex-col font-sans`}>
<input />
<div className={tw`mb-4`}>
<label
className={tw`block text-gray-700 text-sm font-bold mb-2`}
for="todo-name"
>
Name
</label>
<input
className={tw`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline`}
id="todo-name"
type="text"
name="name"
/>
</div>
<button
className={tw`bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline`}
type="submit"
>
Add todo
</button>
</form>
</div>
This form will perform submit a POST request against our index, so we will handle that and make it re-render the page with the list of the current todos, so our handler will become:
export const handler: Handlers<TodosProps> = {
async GET(_req, ctx) {
const allTodos = await listAllTodos();
return ctx.render({
allTodos: allTodos ?? [],
});
},
async POST(req, ctx) {
const formData = await req.formData();
const jsonData = Object.fromEntries(formData);
if (isNewTodo(jsonData)) {
await createTodo({
...jsonData,
done: false,
});
}
const allTodos = await listAllTodos();
return ctx.render({
allTodos: allTodos ?? [],
});
},
};
Note that we will also need to import isNewTodo
, and that in the req
object we can actually fetch the form data from the client: through he form data, the record is created and all the records are returned.
Hence, upon a post request, the server will create the record in case it is a valid Todo item and will render the page with the updated list of Todo Items.
Next, we need to handle the second point: we should allow the final user to toggle the state of a todo, and for the sake of testing the framework we want to do that through an api endpoint (although it's of course unnecessary).
To accomplish so, we will create 4 nested folders inside the routes folder: api
, v1
, todo
and [todoId]
, next we will create inside the [todoId]
folder the toggleState.ts
file: the final endpoint will be /api/v1/todo/[todoId]/toggleState
, and toggleState.ts
will hold this code:
import { Handlers } from "$fresh/server.ts";
import toggleTodoDone from "../../../../../core/data/todo/toggle-todo-done.ts";
export type ToggleStateResponse = {
modifiedCount: number;
};
export const handler: Handlers<ToggleStateResponse> = {
async PUT(_req, ctx) {
const { modifiedCount } = await toggleTodoDone(ctx.params.todoId);
return new Response(
JSON.stringify({
modifiedCount,
}),
{
headers: {
"content-type": "application/json",
},
}
);
},
};
As you can see, differently from the previous index file, we are only using the Handler to actually handle the PUT
method against this route, to alter the data on the database and to return a simple response with content-type application/json.
Finally, we should now handle how the client will request the change to the backend and, because it is preact... I do that with an hook.
Because such hook might be used somewhere else for whatever reasons, I usually like to keep ui elements and utilities in a separate folder, so we are going to make a ui
folder with an hooks
folder inside it, so that we can later use it.
Since our endpoint returns an information that we don't need, we will just need to handle three scenarios:
- The request is pending (loading).
- The request raised an error.
- The request ended with a positive status.
To handle all of these, our /ui/hooks/useToggleTodoState.tsx
hook will look like this:
/** @jsx h */
import { h } from "preact";
import { useCallback, useState } from "preact/hooks";
export default function useToggleTodoState(onSuccess: () => void) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const toggleTodoState = useCallback(
(todoId: string) => {
setLoading(true);
setError(void 0);
return fetch(`/api/v1/todo/${todoId}/toggleState`, {
method: "PUT",
})
.then((data) => {
setLoading(false);
if ([200, 204].includes(data.status)) {
onSuccess();
}
})
.catch((error) => setError(error))
.finally(() => setLoading(false));
},
[setLoading, setError, onSuccess]
);
return {
loading,
error,
toggleTodoState,
};
}
Please note the onSuccess
callback that is meant to be invoked upon a successful response: this hook will allow us to invoke the API endpoint and update the status of a todo, now we just need to wire things up with a component that displays the todo and allows us to update its status.
Because the component is meant to add interaction to the index page, it will be located in the islands
folder and will be hydrated after the page is rendered: therefore, since our component will render a TodoItem, we will call it TodoItem.tsx
and it will be like this:
/** @jsx h */
import { h } from "preact";
import { useCallback, useState } from "preact/hooks";
import { tw } from "@twind";
import useToggleTodoState from "../ui/hooks/useToggleTodoState.tsx";
import { Todo } from "../core/data/models/todo.ts";
export type TodoItemProps = {
value: Todo;
};
export default function TodoItem(props: TodoItemProps) {
const { value } = props;
const [done, setDone] = useState(value.done);
const onStatusUpdated = useCallback(() => {
setDone((prev) => !prev);
}, [setDone]);
const { loading, error, toggleTodoState } =
useToggleTodoState(onStatusUpdated);
const toggleState = useCallback(() => {
toggleTodoState(value._id.toString());
}, [value]);
return (
<div className={tw`flex mb-4 items-center`}>
<p className={tw`w-full text-grey-darkest`}>{value.name}</p>
<button
disabled={loading}
onClick={toggleState}
className={tw`flex-no-shrink p-2 ml-4 mr-2 border-2 rounded text-green border-green hover:bg-green`}
>
{done ? "Mark as not done" : "Mark as done"}
</button>
{error}
</div>
);
}
As you can see it just looks like a regular react component, although you might notice that it is using models taken from the core
folder but because in the models file we are using nothing incompatible it won't affect the rendering of the page.
Now, we just need to use the component in our homepage, so we will replace the list placeholder with this item and our homepage list section will be this:
<div
className={tw`bg-white rounded shadow p-6 m-4 w-full lg:w-3/4 lg:max-w-lg`}
>
<h2>Todo Items - Items uses the API endpoints to update data.</h2>
<hr className={tw`mb-1`} />
{allTodos.map((todo) => (
<TodoItem value={todo} />
))}
</div>
Now that everything is wired up, we will also configure Docker to run in a production-like environment.
Chapter 4: Dockerfile and configuring for production
In a production environment, two steps should be taken:
1) Build the docker image and tag it.
2) Use it in some way.
In our case, we will be assuming for testing purposes that for scenario 2 we will be using docker-compose, but this solution actually works with anything that can actually run an image.
First of all, we will need to write our Dockerfile:
FROM denoland/deno:alpine-v1.23.0
EXPOSE 8000
WORKDIR /app
COPY . .
# Cache dependencies
RUN deno cache main.ts --import-map=import_map.json
# No need to check about --allow-all because it's already sandboxed
CMD ["run", "--allow-all", "main.ts"]
Briefly explained: we are using the 1.23.0 alpine Deno image, exposing port 8000 (used by fresh by default), caching the dependencies and running main.ts
. This will listen on port 8000 and won't watch any file.
Now, we need to make a docker-compose that uses the image an we need to tag it. For this example, we will assume our image will be named fresh-todo-app-test
.
Our docker-compose will look like this:
version: "3.4"
services:
todo-app-fresh:
image: fresh-todo-app-test:latest
container_name: todo-app-fresh
restart: always
ports:
- ${PUBLIC_PORT:-8000}:8000
environment:
MONGODB_URI: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@todo-mongo:27017/
networks:
- todo-app-network
todo-mongo:
image: mongo:latest
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
networks:
- todo-app-network
volumes:
- mongo-data:/data/db
mongo-express:
image: mongo-express:latest
restart: always
ports:
- 8081:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGODB_USERNAME}
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGODB_PASSWORD}
ME_CONFIG_MONGODB_URL: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@todo-mongo:27017/
networks:
- todo-app-network
networks:
todo-app-network:
volumes:
mongo-data:
Please not that the environment
in docker-compose is set up assuming that the mongodb username and password are in the .env file, so we will also need to update our .env
file to have these:
.env
:
MONGODB_URI=mongodb://root:example@127.0.0.1:27017/
MONGODB_USERNAME=root
MONGODB_PASSWORD=test
the first variable will be used locally, while the second and third variables will be used in docker-compose.
Now that everything is set up, we will update our Makefile
with the commands needed to build and deploy the stack:
Makefile
build-and-tag-image:
docker build -t fresh-todo-app-test .
deploy-stack:
docker-compose -f ./docker-compose.yml --env-file .env down
docker-compose -f ./docker-compose.yml --env-file .env rm
docker-compose -f ./docker-compose.yml --env-file .env build
docker-compose -f ./docker-compose.yml --env-file .env up -d --remove-orphans
build-and-deploy-stack: build-and-tag-image deploy-stack
stack-down:
docker-compose -f ./docker-compose.yml --env-file .env down
docker-compose -f ./docker-compose.yml --env-file .env rm
Now, running make build-and-deploy-stack
you should see the stack coming up and it should be available in seconds, you might notice some restarts in case the Deno app starts but the mongodb database is not yet ready.
Finally, the application should be ready and we did a todo app with fresh!
Chapter 5: conclusions
Fresh seems to be a very interesting framework with, in my opinion, a very specific target: monolithic applications (or at least something with frontend and backend together) with SSR on steroids and an opinionated structure with a particular attention on keeping things simple but at the same time incredibly elastic.
It's a pleasure, in my opinion, to see something built on Deno that is currently at least trying to shade a bit the current huge javascript frameworks, although I'm feeling that many targets are still way more suitable for next.js and similar frameworks.
However, since we talked about great things only, I feel we should also mention that there actually are scenarios where the framework does not yet shine that much, which is everything where styling is involved.
Currently, the framework supports tailwind out of box, but in case you want to use a simple custom CSS you will need to wrap your head around assets and manually do assets optimisation, meaning that there is literally no tooling around this issue. To give a comparison, next.js compiles .scss files and whatever kind of style you want through react and will ship the bundled styles by tree shaking and, thus, optimising the final bundle.
That said, it looks like this framework can have a bright future, I hope you've found the post useful!
Posted on July 3, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.