One of the interesting things about using static site generators (SSGs) is the ability to spin up apps and make additions and changes to them easily. This makes it perfect for building blogs because of the constant article additions and editing that happens with blogs.
In this article, we’ll be using Capri, a static site generator that generates static sites using the islands architecture model.
Jump ahead:
Ordinarily, Capri doesn’t ship any JavaScript to the frontend by default, just static HTML and CSS. To handle interactivity and dynamic data on our Capri blog, we need to hydrate it by using an *island.*
suffix. This means that if you’re fetching data or handling some interactivity with your component, you can name it componentname.island.jsx
. This name format depends on the framework/library we’re using — for instance, if we’re using Vue, it’ll be componentname.island.vue
.
In cases where we need to render both static and dynamic content in the same component, or when we have a dynamic component that passes props into a static component, we use the *lagoon.*
suffix. The aim of this is to ensure that the site is faster and hydrates dynamic components only when necessary.
Let’s spin up our blog using React and Capri. To trigger a quickstart, run this command in your terminal:
npm init capri my-capri-site -- -e react
It sets up a sample Capri site in React. We’ll have to remove the boilerplate code and add custom components for our project.
We can also use Capri with other popular UI frameworks that have SSR support, like Vue, Svelte, Preact, etc.:
You should know that Capri offers a preview mode that enables hot reloads while running our app locally. It renders our app locally as a SPA.
import { Navigate } from "react-router-dom"; /** * Handle preview requests like `/preview?slug=/about` by redirecting * to the given slug parameter. */ export default function Preview() { const url = new URL(window.location.href); const slug = url.searchParams.get("slug") ?? "/"; return <Navigate to={slug} />; } /** * Component to display a banner when the site is viewed as SPA. */ export function PreviewBanner() { return <div className="banner">Preview Mode</div>; } // src/preview.tsx
We wrap the preview component around the <App/>
component:
import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import { PreviewBanner } from "./Preview.jsx"; ReactDOM.createRoot(document.getElementById("app")!).render( <StrictMode> <BrowserRouter basename={import.meta.env.BASE_URL}> <PreviewBanner /> <App /> </BrowserRouter> </StrictMode> ); // src/main.tsx
So, for our server rendering, the entry file looks like this:
import { RenderFunction, renderToString } from "@capri-js/react/server"; import { StrictMode } from "react"; import { StaticRouter } from "react-router-dom/server.js"; import { App } from "./App"; export const render: RenderFunction = async (url: string) => { return { "#app": await renderToString( <StrictMode> <StaticRouter location={url} basename={import.meta.env.BASE_URL}> <App /> </StaticRouter> </StrictMode> ), }; }; // src/main.server.tsx
Another thing to note is that Capri uses Vite out of the box for its config.
import capri from "@capri-js/react"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ react(), capri({ spa: "/preview", }), ], });
Finally, we have the root index.html
in our root folder:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" type="image/svg+xml" href="/src/capri.svg" /> </head> <body> <div id="app"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
We’ll be building a Rick and Morty blog, where each post contains information about characters in the series.
Let’s build our blog’s home page. Create a Home.tsx
file in our src
folder. It’s the first page the user sees once they land on the blog. It’s going to be a static component.
export default function Home() { return ( <main> <h1>Welcome To Our Rick and Morty Blog</h1> <h5> Each blog post contains information about individual characters in the series. </h5> <Posts /> </main> ); } // src/Home.tsx
We can now go into our App.tsx
file and import our Home.tsx
file.
import "./App.css"; import { Suspense } from "react"; import Preview from "./Preview.jsx"; import { Route, Routes } from "react-router-dom"; import Home from "./Home"; export function App() { return ( <Suspense fallback={<div>loading...</div>}> <Routes> <Route index element={<Home />} /> <Route path="/preview" element={<Preview />} /> </Routes> </Suspense> ); } // src/App.tsx
We wrap our component using React Router and Suspense
to handle our app’s routing and data fetching, respectively.
Next, we need to display the list of posts. To do that, let’s create a Posts
component in a file we’ll call Posts.lagoon.tsx
:
import { Link } from "react-router-dom"; export default function Posts() { const container = { display: "grid", }; const containerItem = { paddingBottom: "3rem", }; return ( <div style={container}> {[...Array(10)].map((x, i) => ( <div style={containerItem}> <Link to={`/post/${i + 1}`}> Post/{i + 1} </Link> </div> ))} </div> ); } // Posts.lagoon.tsx
Here, we loop through ten items in an array and display ten posts. Each item has a Link
(from React Router) that uses dynamic parameters to pass an id
to the page. We’ll get to that id
later in the article.
For now, let’s import the Posts.lagoon.tsx
component into our Home.tsx
component.
import Posts from "./Posts.lagoon"; export default function Home() { return ( <main> <h1>Welcome To Our Rick and Morty Blog</h1> <h5> Each blog post contains information about individual characters in the series. </h5> <Posts /> </main> ); }
We list out the ten post items. As previously mentioned, we’re currently in preview mode since we’re still running the app locally.
Then, we need to create a new component that’s called the PostItem.lagoon.tsx
file to display each post.
import { Link, useParams } from "react-router-dom"; import axios from "axios"; import useSWR from "swr"; export default function PostItem() { let { id } = useParams(); const url = `https://rickandmortyapi.com/api/character/${id}`; const fetcher = (url: string) => axios.get(url).then((res: any) => res.data); const { data, error } = useSWR(url, fetcher, { suspense: true }); if (error) return <div>failed to load</div>; return ( <main> <h1>{data.name}</h1> <img src={data.image}></img> <section>Status: {data.status}</section> <section>Species: {data.species}</section> <section>Number of episodes featured: {data.episode.length}</section> <section> <h5>Location: {data.location.name}</h5> <h5>Gender: {data.gender}</h5> </section> <Link to="/">Back Home</Link> </main> ); }
Within this component, we are going to retrieve the id
from Posts.lagoon.tsx
. We’ll make use of the useParams
Hook from our React Router instance to get the current id
from our URL params. We’ll then take this id
and append it to our Rick and Morty endpoint.
To call this endpoint, we’ll make use of useSWR
and Axios. To use this, we’ll have to install both packages:
yarn add swr yarn add axios
Next, we write a fetcher
function that receives the endpoint, makes a GET
request using axios
, and returns the response.
const fetcher = (url: string) => axios.get(url).then((res: any) => res.data); const { data, error } = useSWR(url, fetcher, { suspense: true });
We then make the actual request using useSWR
. We pass in the fetcher
function and assign suspense
as true
to handle our loading state.
The loading state is the temporary timeframe when we’re waiting for a response from the endpoint. If our requests work as expected, we assign the response to data
. If there’s an error, we’ll display <div>failed to load</div>
.
After this, we can display all the data within the component. We’ll also add a Link
that’ll take us back to the home page.
<main> <h1>{data.name}</h1> <img src={data.image}></img> <section>Status: {data.status}</section> <section>Species: {data.species}</section> <section>Number of episodes featured: {data.episode.length}</section> <section> <h5>Location: {data.location.name}</h5> <h5>Gender: {data.gender}</h5> </section> <Link to="/">Back Home</Link> </main>
Let’s now import this PostItem.lagoon.tsx
component into our App.tsx
as a route.
import "./App.css"; import { Suspense } from "react"; import { Route, Routes } from "react-router-dom"; import Home from "./Home"; import Preview from "./Preview.jsx"; import PostItem from "./PostItem.lagoon"; export function App() { return ( <Suspense fallback={<div>loading...</div>}> <Routes> <Route index element={<Home />} /> <Route path="/preview" element={<Preview />} /> <Route path="/post/:id" element={<PostItem />} /> </Routes> </Suspense> ); } // App.tsx
The live demo is right here, while the code is hosted on my GitHub.
Congratulations🎉. We’ve successfully gotten started with Capri and spun up our own blog in record time.
If you’re looking for another project, you can merge the superpowers of Capri with headless content management systems (CMS) like Contentful or Storyblok. These CMSs act as a source for our data. You can upload the data to your preferred CMS and pull it into your Capri app.
There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.
LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.
LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Build confidently — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.