Building a full stack app with Deno Fresh and Fauna
The best way to learn about new technology is by building something practical. In this tutorial, you will build a simple blogging platform with the Fresh framework and explore various features of Fresh.
Fresh is a next-generation JavaScript framework built on top of Deno. Fresh requires no configuration and comes with cool features such as just-in-time rendering, no build step, out-of-box Typescript support, and island architecture (also known as partial hydration).
You can play around with the live demo app here. You can also find the complete code for this tutorial in this Github repository.
Pre-requisites
- Deno 1.x
- Familiarity with React
- Some Familiarity with TypeScript
Creating a Fresh App
Run the following commands to scaffold a new Fresh application.
deno run -A -r https://fresh.deno.dev my-project
cd my-project
deno task start
Visit localhost:8080 and you are presented with a new Fresh app.
Open the project in your favourite code editor. You will observe a folder structure similar to the following one.
If you are familiar with React you will feel right at home with Fresh. Fresh uses Preact, a minimal version of React.
Fresh uses server side rendering. It compiles javascript in the server side and ships pure HTML to the client. Parts of a server-rendered page can then be independently re-hydrated with interactive widgets (islands). This architecture pattern is called island architecture. You can learn more about Fresh architecture here.
Creating a Navbar and application layout
Create a new file components/NavComponent.tsx
and add the following code.
// components/Navbar.tsx
const logoStyle = `inline-flex items-center border-b-2 border-purple-500 px-1 pt-1 text-sm font-medium text-gray-900`;
export default function Navbar() {
return (
<nav class="bg-white shadow">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="ml-6 flex space-x-8">
<a
href="/"
class={logoStyle}
>
Fresh Blogs πΏ
</a>
</div>
</div>
</div>
</div>
</nav>
);
}
Next, create a new file, routes/_app.tsx
and add the following code.
// routes/_app.tsx
import { AppProps } from "$fresh/server.ts";
import NavComponent from "../components/Navbar.tsx";
export default function App({ Component }: AppProps) {
return (
<>
<NavComponent />
<Component />
</>
);
}
You define your application layout in this file. Notice that you are importing the NavComponent in the _app.tsx
file. This makes the NavComponent visible in every pages of your application.
Next, go ahead and update the index.tsx
file with following code.
// routes/index.tsx
export default function Home() {
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1 class="my-6 text-3xl">
Welcome to `π₯€ Fresh Blogs π`!
</h1>
<p class="my-6">Fresh ideas everyday</p>
</div>
);
}
Now you have an application layout. Next, letβs go ahead and create some page routes.
Creating new routes (New Post page)
Create a new file routes/posts/new.tsx
and add the following code.
// routes/posts/new.tsx
import { PageProps } from "$fresh/server.ts";
import { Fragment } from "preact";
export default function NewPostPage(props: PageProps) {
return (
<Fragment>
<div class="p-4 mx-auto max-w-screen-md">
New Post Page
</div>
</Fragment>
)
}
In the code above you are exporting a new react component.
Fresh automatically generates a new route for your application when you create a new react component inside the routes
directory. Observe the fresh.gen.ts
file and you will notice that your newly created routes are added to this file automatically.
Visit the /posts/new
route and notice that the component above gets rendered.
You can also create a handler function in your routes file. The handler function will be executed on the server side whenever someone visits the route. Here's an example.
// routes/posts/new.tsx
import { PageProps } from "$fresh/server.ts";
import { Fragment } from "preact";
import { HandlerContext, Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
GET(req: Request, ctx: HandlerContext) {
console.log('Inside GET handler');
return ctx.render({
message: "Hello New Post π",
});
},
};
export default function NewPostPage(props: PageProps) {
return (
<Fragment>
<div class="p-4 mx-auto max-w-screen-md">
{props.data.message}
</div>
</Fragment>
)
}
You can also pass data from your handler function to your component using the context render
function as shown in the previous code block.
Next, letβs create a form to create new posts. A form has client side interactivity. In fresh you use islands to handle client side interactivity.
Islands are isolated Preact components that are rendered on the client. Islands are defined by creating a file in theΒ islands/
folder in a Fresh project. Islands are re-hydrated as needed which gives Fresh the extra performance boost unlike other SSR solutions for React.
Create a new file islands/PostForm.tsx
and add the following code.
// islands/PostForm.tsx
import { useState } from "preact/hooks";
export const inputStyle = `p-4 border-2 border-purple-400 radius rounded-md flex w-9/12`;
export default function PostForm() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const createPost = (e: Event) => {
e.preventDefault();
}
return (
<div class="p-5 m-5">
<h1 class="text-xl">Post Form</h1>
<div class="mt-1 flex">
<input
class={`${inputStyle}`}
placeholder="Post Title"
onChange={(e: any) => setTitle(e.target.value)}
value={title}
/>
</div>
<div class="mt-1 flex">
<textarea
class={`${inputStyle}`}
placeholder="Write something..."
onChange={(e: any) => setContent(e.target.value)}
value={content}
/>
</div>
<button
onClick={createPost}
class="rounded-md mt-3 border-transparent bg-purple-200 px-4 py-2"
>
Submit
</button>
</div>
);
}
Import the island component in the routes/posts/new.tsx
file.
// ...routes/posts/new.tsx
import PostForm from "../../islands/PostForm.tsx";
//...
export default function NewPostPage(props: PageProps) {
return (
<Fragment>
<div class="p-4 mx-auto max-w-screen-md">
<PostForm />
</div>
</Fragment>
)
}
Dynamic routes (View Post page and Edit Post page)
Fresh supports dynamic routes similar to Next.js. Folder structure determines the routing mechanism. Create a new file routes/posts/[id]/index.tsx
and add the following code.
// routes/posts/[id]/index.tsx
import { useState } from "preact/hooks";
import { Handlers, PageProps } from "$fresh/server.ts";
export const handler: Handlers = {
GET(_req, ctx) {
const { id } = ctx.params;
return ctx.render({
content: `Hello Post ${id} π`,
});
}
};
export default function PostPage(props: PageProps) {
const [post, setPost] = useState<any>();
setPost(props.data);
if(!post) {
return <div>Loading...</div>
}
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1>{post.content}</h1>
</div>
)
}
Notice that the directory has a folder with [<name>]
syntax. Based on the folder structure the application sets up a /posts/:id
route. Visit http://localhost:8000/posts/1 to make sure the age is loading as expected.
Letβs create an edit post page next. Create a new file routes/posts/[id]/edit.tsx
and add the following code.
// routes/posts/[id]/edit.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import PostForm from "../../../islands/PostForm.tsx";
import { Fragment } from "preact";
export const handler: Handlers = {
GET(_req, ctx) {
const { id } = ctx.params;
return ctx.render({
data: { id },
});
},
};
export default function EditPostPage(props: PageProps) {
return (
<Fragment>
<div class="p-4 mx-auto max-w-screen-md">
<PostForm />
</div>
</Fragment>
)
}
You now have all the pages ready. Next, letβs go ahead and add a database to the project so you can save and retrieve posts.
Adding Fauna database to the project
Head over to fauna.com and create a new account if you havenβt done already. Next create a new database.
Navigate to Security > Keys in your fauna dashboard. Select New Key to generate a new key.
Select Admin as role. Give your key a name and select Save.
You will recieve a key π like following. Do not share this key.
fnAEx.....
Configuring the Database
For this demo application you need 3 Collections in the database. User
, Post
and Comment
. Additionally you need to define relationship between these collections. There is a has_many
relationship between User
to Post
and Post
to Comment
. Fauna also provides out-of-box user registration and secure authentication.
You can create all these collections from Fauna dashboard in the UI or you can run this setup script to generate all the resources for you. Copy the script into utils/fauna-setup.ts
file.
To run the script first export your fauna admin secret and domain in the shell.
$ export FAUNA_ADMIN_SECRET=<Your-admin-secret> \
export FAUNA_DOMAIN=<Your-db-domain>
Next run the script.
$ deno run ./utils/fauna-setup.ts
What does the script generates?
When the script execution is complete all the collections and indexes will be created in your Fauna database. The script sets up several Fauna resources. It sets up three collections User
Post
and Comment
. It also creates an index to search user by email.
The script will also create a server secret for your application. Your application will use this secret to communicate with the database. Export the server secret by running following command in your terminal.
export FAUNA_SECRET=fnAExE....
You can also create a .env
file to store these environment variables variable. Following is an example of a .env
file. Always make sure not to commit this file with your source control.
FAUNA_DOMAIN=db.us.fauna.com
FAUNA_SECRET=fnAEuxxxx.....
FAUNA_ADMIN_SECRET=fnADxxxxx.....
Finally, you can visit the Fauna dashboard and explore the generated resources in the UI.
Create a new file utils/db.ts
and add the following code. Here you are instantiating a new database client. You use these helper functions to query the database.
// utils/db.ts
import * as faunadb from "https://deno.land/x/fauna@5.0.0-deno-alpha9/mod.js";
export const q = faunadb.query as any;
export const faunaClient = new faunadb.Client({
domain: Deno.env.get("FAUNA_DOMAIN"),
secret: Deno.env.get("FAUNA_SECRET"),
});
export const getFaunaClient = (secret: string) => new faunadb.Client({
domain: Deno.env.get("FAUNA_DOMAIN"),
secret,
});
Setting up user registration
Now that you have the database configured let's go ahead and implement user registration and user login functionality.
First, create a new route for user login. Create a new file, routes/signup.tsx
and add the following code.
// routes/signup.tsx
import SignupForm from "../islands/SignupForm.tsx";
export default function UserSignup() {
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1 class="my-6 text-3xl">
π Let's get you signed up!π
</h1>
<SignupForm />
</div>
);
}
Notice you have a SignupForm
island imported in this page. Go ahead and create a new island islands/SignupForm.tsx
and add the following code.
// islands/SignupForm.tsx
import { useState } from "preact/hooks";
import { inputStyle } from "./PostForm.tsx";
export default function SignupForm() {
const [state, setState] = useState({});
const handleChange = (e: any) => {
setState({
...state,
[e.target.name]: e.target.value,
});
}
const register = async () => {
}
return (
<div>
<div class="pl-4 pt-4 mt-4">
<input
onChange={handleChange}
type="text"
class={`${inputStyle}`}
placeholder="Name"
name="username"
/>
</div>
<div class="pl-4 pt-4 mt-1">
<input
onChange={handleChange}
type="email"
class={`${inputStyle}`}
placeholder="Email"
name="email"
/>
</div>
<div class="pl-4 pt-2 mt-1">
<input
onChange={handleChange}
type="password"
class={`${inputStyle}`}
placeholder="Password"
name="password"
/>
</div>
<div class="pl-4 pt-2 mt-1">
<button
onClick={register}
class="rounded-md mt-3 border-transparent bg-purple-200 px-4 py-2"
>
Register
</button>
</div>
</div>
)
}
On the Register button click, you want to make a call to Fauna to register your users. You can make a direct HTTP call from your SignupForm
island component. However, you will expose the Fauna's secret key by doing this. It is a good practice to make database interactions and third-party API calls from the server side. This way, your API secrets are never exposed on the client side.
You can create a new API route in Fresh, a server-side REST endpoint, then have your island component make a call to this API endpoint and handle all database interactions in the endpoint.
Fresh API routes are similar to Next.js API routes.
Create a new file routes/api/register.ts
and add the following code.
import { Handlers } from "$fresh/server.ts";
import { faunaClient, q } from "../../utils/db.ts";
export const handler: Handlers = {
async POST(req: Request) {
try {
const body = await req.json();
const newUser = await faunaClient.query(
q.Create(
q.Collection("User"),
{
credentials: { password: body.password },
data: {
email: body.email,
username: body.username,
},
}
)
);
return Response.json({
data: {
...newUser.data,
}
});
} catch (error) {
return Response.json({
error: error.message,
});
}
}
};
Here you are creating a new User record in Fauna. You can learn more about Fauna CRUD operations in the official docs here.
Next, plug this api endpoint to your SignupForm
register function as shown in the following code snippet.
// islands/SignupForm.tsx
export default function SignupForm() {
// ...
const register = async () => {
try {
const response = await fetch("/api/register", {
method: "POST",
body: JSON.stringify({
...state,
}),
});
const data = await response.json();
console.log(data);
alert("Successfully registered!, try to log πͺ΅ in now");
} catch (error) {
alert("Something went wrong!");
}
}
return (
<div>
...
</div>
)
}
Visit http://localhost:8000/signup and try to register a new user. Once a new user is registered it should appear under User collection in Fauna.
Notice that your password is not saved as a string. This is because you defined password field as credentials
type. This is a part of Faunaβs secure built in authentication feature.
User Login
You can follow the same pattern to setup user login functionality. Create a new file routes/login.tsx
and add the following code.
// routes/login.tsx
import LoginForm from "../islands/LoginForm.tsx";
export default function UserLogin() {
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1 class="my-6 text-3xl">
Login to your account and start blogging! π
</h1>
<LoginForm />
</div>
);
}
Create a LoginForm
island component to handle the form.
Next, create a new api routes to handle Login.
// routes/api/login.ts
import { Handlers } from "$fresh/server.ts";
import { faunaClient, q } from "../../utils/db.ts";
export const handler: Handlers = {
async POST(req: Request) {
try {
const body = await req.json();
const authUser: any = await faunaClient.query(
q.Login(
q.Match(q.FaunaIndex("users_by_email"), body.email),
{ password: body.password },
)
);
return Response.json({
data: {
token: authUser.secret,
}
});
} catch (error) {
console.log('==>>>', error);
return Response.json({
error: error.message,
});
}
}
};
And finally create a LoginForm
island to handle interactivity.
// islands/LoginForm.tsx
import { useState } from "preact/hooks";
import { inputStyle } from "./PostForm.tsx";
export default function LoginForm() {
const [state, setState] = useState({});
const handleChange = (e: any) => {
setState({
...state,
[e.target.name]: e.target.value,
});
}
const doLogin = async () => {
try {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({...state}),
})
const data = await response.json();
if(data.error) {
alert(`${data.error} : Make sure you have a correct email and password`);
} else {
localStorage.setItem("token", data.data.token);
alert("Successfully logged in!");
}
} catch (error) {
console.log(error);
alert("Something went wrong!");
}
}
return (
<div>
<div class="pl-4 pt-4 mt-4">
<input
onChange={handleChange}
type="email"
class={`${inputStyle}`}
placeholder="Email"
name="email"
/>
</div>
<div class="pl-4 pt-2 mt-1">
<input
onChange={handleChange}
type="password"
class={`${inputStyle}`}
placeholder="Password"
name="password"
/>
</div>
<div class="pl-4 pt-2 mt-1">
<button
onClick={doLogin}
class="rounded-md mt-3 border-transparent bg-purple-200 px-4 py-2"
>
Signin
</button>
</div>
</div>
)
}
You use the Login function from Fauna to generate a temporary user secret (it is invalidated after some time). You save this secret in your local storage just like a jwt
token. You need to pass this secret with your API call to Fauna when making protected requests (i.e. create or delete posts).
Head over to http://localhost:8000/login and try logging in with a registered user.
Notice on successful login the Fauna secret is saved in the local storage.
βΉοΈΒ You can learn more about Faunaβs identity based authentication here.
Adding CRUD for Posts
Following a similar pattern, you can add CRUD operations for posts. Create a new API route to handle post CRUD operations. Create a new file routes/api/post.ts
and add the following code.
// routes/api/post.ts
import { Handlers } from "$fresh/server.ts";
import { getFaunaClient, q } from "../../utils/db.ts";
export const handler: Handlers = {
/**
* Create a new post
*/
async POST(req: Request) {
try {
const body = await req.json();
const faunaClientWithAuth = getFaunaClient(req.headers.get("Authorization")!);
const newpost = await faunaClientWithAuth.query(
q.Create(
q.Collection('Post'),
{
data: {
...body,
owner: q.CurrentIdentity(),
author: q.Select(["data", "username"], q.Get(q.CurrentIdentity()))
}
},
)
);
return Response.json({
data: {
_id: newpost.ref.id,
...newpost.data,
}
});
} catch (error) {
return Response.json({
error: error.message,
});
}
},
};
In the previous code block, you use the getFaunaClient
function and pass in the Authorization
header. The Authorization header is a Fauna secret that you save in your local storage on successful login.
On PostForm
island component plug in the api call when submit button is clicked.
// islands/PostForm.tsx
...
export default function PostForm() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const createPost = async (e: Event) => {
e.preventDefault();
const response = await fetch("/api/post", {
headers: {
"Authorization": localStorage.getItem("token") || "",
},
method: "POST",
body: JSON.stringify({ title, content, author: localStorage.getItem("username") }),
});
const data = await response.json();
console.log(data);
if(data.error) {
alert('β Something went wrong! Are you logged in?');
return;
}
alert('Post created!');
setTitle("");
setContent("");
}
return (
<div class="p-5 m-5">
...
</div>
);
}
Navigate to http://localhost:8000/posts/new and try creating a new post. Make sure you are already logged in.
Showing a list of posts
In the home page a user should be able to view all the posts. In your routes/api/post.ts
file add a new GET function to retrieve all the posts.
import { Handlers } from "$fresh/server.ts";
import { getFaunaClient, q, faunaClient } from "../../utils/db.ts";
export const handler: Handlers = {
/**
* Create a new post
*/
async POST(req: Request) {
//...
},
/**
* Get all posts
*/
async GET() {
try {
const posts: any[] = [];
const { data } = await faunaClient.query(
q.Map(
q.Paginate(q.Documents(q.Collection('Post'))),
q.Lambda("post", q.Get(q.Var("post")))
)
);
data.forEach((post: any) => {
posts.push({
_id: post.ref.id,
...post.data,
});
})
return Response.json({
data: posts,
});
} catch (error) {
return Response.json({
error: error.message,
});
}
},
};
Create a new island component, islands/PostList.tsx
to display all the posts.
// islands/PostList.tsx
import { useState, useEffect } from "preact/hooks";
export default function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await fetch("/api/post");
const remotePosts = await response.json();
setPosts(remotePosts.data);
} catch (error) {
console.error(error);
alert("Something went wrong!");
}
}
fetchPosts();
},[]);
console.log(posts);
return (
<div class="p-5">
<h1 class="text-xl">Post List</h1>
{
posts.map((post: any) => (
<PostItem post={post} id={post._id} />
))
}
</div>
)
}
function PostItem({ post, id } : { post: any, id: string }) {
return (
<div class="p-5">
<h4 class="text-md mb-3">{post.title}</h4>
<a class="border mt-1 p-2 cursor:pointer" href={`/posts/${id}`}>View</a>
</div>
)
}
Add this island component into your index.tsx
page.
// routes/index.tsx
import PostList from "../islands/PostList.tsx";
export default function Home() {
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1 class="my-6 text-3xl">
Welcome to `π₯€ Fresh Blogs π`!
</h1>
<p class="my-6">Fresh ideas everyday</p>
<PostList />
</div>
);
}
All the posts will now appear in your homepage.
Viewing a single post
Notice that when you select the View button for any post from post list it takes you to the /post/:id
route. In this page you can retrieve the post information and display it.
In this case, you can use the handler function from fresh to retrieve the data from the database. Since the handler function runs on the server side, you are not exposing any secure information. Make the following changes to your routes/posts/[id]/index.tsx
file.
import { useState } from "preact/hooks";
import { Handlers, PageProps } from "$fresh/server.ts";
import { faunaClient, q } from "../../../utils/db.ts";
export const handler: Handlers = {
async GET(_req, ctx) {
const { id } = ctx.params;
try {
const post = await faunaClient.query(
q.Get(q.Ref(q.Collection('Post'), id))
);
return ctx.render({
_id: post.ref.id,
...post.data,
});
} catch (error) {
return Response.json({
error: error.message,
});
}
},
};
export default function PostPage(props: PageProps) {
const [post, setPost] = useState<any>();
setPost(props.data);
if(!post) {
return <div>Loading...</div>
}
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1 class="text-xl pl-4">{post.title}</h1>
<div class="text-sm font-bold pl-4">By {post.author}</div>
<p class="pl-4 text-left">{post.content}</p>
</div>
)
}
Deleting a post
Add a DELETE
function to your routes/api/post.ts
api route to handle post delete.
// routes/api/post.ts
import { Handlers } from "$fresh/server.ts";
import { getFaunaClient, q, faunaClient } from "../../utils/db.ts";
export const handler: Handlers = {
/**
* Create a new post
*/
async POST(req: Request) {
...
},
/**
* Get all posts
*/
async GET() {
...
},
/**
* Delete a post
*/
async DELETE(req: Request) {
try {
const body = await req.json();
const faunaClientWithAuth = getFaunaClient(req.headers.get("Authorization")!);
const post = await faunaClientWithAuth.query(
q.Delete(q.Ref(q.Collection('Post'), body._id))
);
return Response.json({
data: post.data,
});
} catch (error) {
return Response.json({
error: error.message,
});
}
},
};
Next, make the Delete
call from your page component to delete the post.
// routes/posts/[id]/index.tsx
// ...
export default function PostPage(props: PageProps) {
const [post, setPost] = useState<any>();
setPost(props.data);
const deletePost = async () => {
const response = await fetch(`/api/post/`, {
headers: {
"Authorization": `${localStorage.getItem("token")}`,
},
method: "DELETE",
body: JSON.stringify({
_id: post._id
})
});
const data = await response.json();
if(data.error) {
alert(data.error);
} else {
alert("Post deleted!");
window.location.href = "/";
}
}
if(!post) {
return <div>Loading...</div>
}
return (
<div class="p-4 mx-auto max-w-screen-md">
<h1 class="text-xl pl-4">{post.title}</h1>
<div class="text-sm font-bold pl-4">By {post.author}</div>
<p class="pl-4 text-left">{post.content}</p>
<button class="border mt-1 p-2 cursor:pointer" onClick={deletePost}>Delete</button>
</div>
)
}
You can also apply the same pattern and have CRUD for comments. You can follow the completed code for this project here to implement comments CRUD functionality.
Final thoughts
Fresh really takes a fresh new approach to web dev. It is still quite new but the ecosystem is rapidly growing. Up until now the Deno ecosystem was a missing a full stack framework and Fresh seems to fill that void quite effectively. You can create scalable, performant applications with Fresh and Fauna quite easily. On top of that you can make fully serverless full stack applications on the edge when you deploy your Fresh app to Deno deploy, denoflare.dev or Netlify.
Top comments (3)
Great article thanks. I wonder where you found out about the special
routes/_app.tsx
file. I did not find it mentioned in the docs.Think I saw it in one of the sample apps
Greatissime, thank you very much :-)