Introducing surveydown: A markdown-based framework for generating surveys with Quarto and shiny

R
package
markdown
shiny
quarto

A quick overview of the {surveydown} R package for making markdown-based surveys with open-source technologies: Quarto, shiny, and supabase.

Author

John Paul Helveston

Published

2024-08-21


Important

This post was made just after launching surveydown. Much of the platform architecture has changed since then, so check the documentation for the latest correct information.

Note

Note: This post is largely a copy of the a similar post I made on my personal website, with some minor edits.

This post introduces the {surveydown} R package, a new way to design surveys using markdown, R, Quarto, and shiny. The idea for this platform has been brewing for a while (see this blog post for more on the motivation for this project), but now the package is finally here!

In this post, I’m going to show you a quick overview of the {surveydown} R package for making markdown-based surveys as well as a little about why we built surveydown.

A new way to design surveys

surveydown is a flexible platform for making surveys in using three open source technologies: Quarto, shiny, and supabase. The package is still in development, but you can already use it to create surveys.



The basic concept is this:

  1. Design your survey as a Quarto shiny document using markdown and R code.
  2. Render your doc into a shiny app that can be hosted online and sent to respondents.
  3. Store your survey responses in a supabase database.

In surveydown, your entire survey is designed using markdown and R code in a single Quarto document. There is no GUI or drag-and-drop interface - simply write plain text (markdown and R code) and boom - you have a survey!

The {surveydown} package provides a set of functions for defining the survey content and control logic. Each function starts with sd_ to make them easy to identify. You can add content to your survey using markdown formatting, or in RStudio you can edit with the visual editor. Survey questions are defined in R code chunks with the sd_question() function. Pages are defined using fences (:::), and navigation buttons handled with the sd_next() function. You can modify the control logic in the server code chunk (the last code chunk at the bottom of the .qmd file) with the sd_config() function, and you can configure the database with the sd_database().

The documentation has many more details on all of this, and later in this post I’ll give a quick overview of a few of these features. But first, let me tell you a little about why we decided to build surveydown.

Why did we build surveydown?

Do we really need another survey platform?

Like many researchers who do a lot of survey work, I’ve been frustrated with most survey platforms available. Commerical platforms like Qualtrics and SurveyMonkey are great, but they are expensive and are difficult to version control, and collaboration with others is near impossible, especially if your collaborator doesn’t have a license of their own. They also don’t allow me to own my own survey data, meaning I always am at the mercy of the platform owner. And the features are often limited. It’s not easy to do more complex things like randomization, conditional display, etc.

The only other open-source survey platform I have used is formr, which is a very powerful platform, but it is rather clunky to use (you define your survey in Google sheet cells…there’s a learning curve), and it is not as easy to edit as a simple markdown file.

What we’ve come up with is a survey platform that is flexible, relatively easy to use, and built entirely with open-source technologies. I think it solves a lot of problems, and hopefully someone out there will find it useful.

Open source

surveydown is built entirely with open-source technologies, making it transparent and customizable. Best of all, no expensive licenses! Just install and use it!

Own your data

With surveydown, you retain full ownership and control of your survey data. The responses are stored in your own Supabase database, ensuring that you have complete access to the data. This is particularly important for researchers dealing with sensitive information or those who need to comply with specific data protection regulations. We’re still working on enabling you to use your own hosted database, which will provide even more flexibility.

Ease of editing

Designing a survey in surveydown is a pretty straightforward process. The markdown-based approach allows for quick modifications and easy navigation through your survey. And since it’s built on Quarto, you can use all of the features of Quarto to make your survey look great, like changing the theme, adding custom CSS, etc. You can also easily preview your survey as you edit it, and even run your survey locally to test it out before you deploy it, either with a button click in RStudio or with a quarto serve command in the terminal.

Did I mention you can run R code in your survey?

Every surveydown survey uses R code chunks for questions. But you can also insert R code for all sorts of other things. For example, if you wanted to randomize the values shown in a question, you could write some R code for that. Want to insert a plot of something? Write a little ggplot code. You can also add interactive components to your surveys, such as showing a respondent how their responses compare to others in real time.

Easy version control and collaboration

Because the entire survey is defined in a single plain text file, surveydown naturally integrates with version control systems like Git. This allows you to track changes over time, collaborate with team members, and maintain a clear history of your survey’s development.

Reproducible

Surveydown promotes reproducible research by allowing you to define your entire survey in a single, self-contained plain text document. This has a ton of benefits:

  • Want someone else to be able to reproduce your experiment? Just give them the .qmd file and any other files they need (e.g., images, data, etc.), and they can reproduce your survey on their own computer.
  • Want to print out your survey for an appendix? Render the survey with all pages visible then print it to pdf.
  • Want others to see your survey live? Just set the database into pause mode and your survey will function without recording any responses.

Reproducibility is something we had in mind from the start with this project, and we’ve tried to make it as easy as possible for your surveydown surveys to be fully reproducible.

Introduction to surveydown

Getting started

After getting everything installed, we recommend starting with a template survey project. To do so, run the following in the R console:

surveydown::sd_create_survey("path/to/folder")

This will create a folder with the following files:

  • example.qmd: a template survey you should edit.
  • example.Rproj: An RStudio project file (helpful if you’re working in RStudio)
  • _extensions: A folder with the surveydown Quarto extension needed to make everything work (don’t modify this).

If you have the example open in RStudio, you can click the “Run document” button, or in your terminal run quarto serve example.qmd. Either approach should render the example survey into a shiny app that you can preview in a browser. Don’t worry just yet about setting up your database or making the survey live - for now, we’re going to focus on designing the survey and running it locally to preview it. The example survey should look like this:

Adding pages

In surveydown, pages are delineated using “fences”, like this:

::: {#welcome .sd-page}

Page 1 content here

:::

::: {#page2 .sd-page}

Page 2 content here

:::

As you can see, we use three colon symbols :::, called a “fence”, to mark the start and end of pages. This notation is commonly used in Quarto for a variety of use cases, like defining subfigures in images.

In the starting fence, you need to define a page name (e.g. welcome and page2 in the example above) and you need to define the class as .sd-page. Then anything you put between the page fences will appear on that page.

To navigate to the next page, you need to insert a sd_next() function call inside a code chunk, like this:

```{r}
sd_next(next_page = 'page2')
```

The above code chunk will create a “Next” button that goes on to page 2 that looks like this:

You would need to place the code chunk in between the ::: fences of the welcome page in order to have a “Next” button that goes on to page 2. You can also send the user to other pages by just changing the next_page argument. Finally, you can also change the label of the button by changing the label argument, like this:

```{r}
sd_next(next_page = 'page2', label = 'Next page')
```

Adding questions

Every survey question is created using the sd_question() function inside a code chunk. The question type is defined by the type argument. For example, to add a multiple choice question, you could insert the following code chunk:

```{r}
sd_question(
  type  = 'mc',
  id    = 'penguins',
  label = "Which is your favorite type of penguin?",
  option = c(
    'Adélie'    = 'adelie',
    'Chinstrap' = 'chinstrap',
    'Gentoo'    = 'gentoo'
  )
)
```

The above code chunk will create a multiple choice question that looks like this:


The sd_question() function can be used to create a variety of question types, like text input, select drop down choices, and more by changing the type argument.

The function has many other arguments for customizing the look and feel of the question (e.g., height and width, etc.).

The server chunk

At the very bottom of the .qmd file is a special “server” code chunk (that’s the #| context: server bit) that defines the app server. This is where you can customize and control the survey flow logic as well as where you define the database that will store the survey response data. It looks like this:

```{r}
#| context: server

# Define the database settings
db <- sd_database()

# Define the configuration settings
config <- sd_config()

# The sd_server() function initiates your survey - don't change this
sd_server(
  input   = input,
  session = session,
  config  = config,
  db      = db
)
```

The sd_database() function is where you set up your database. The sd_server() function makes everything run, which you can safely ignore - just don’t change it and all will be good!

The middle part (the sd_config() function) is where you can define custom control logic for the survey, such as conditional display (conditionally displaying a question based on responses to questions), or conditional skip (conditionally sending the respondent to a page based on responses to questions).

Going live!

Once you are happy with your survey, you can deploy it live to any server of your choice. Since it’s a shiny app, you can deploy it to shinyapps.io for free!

Features

Since surveydown is built on top of Shiny, it provides tremendous flexibility in terms of what you can do with your survey. Below are a few examples of some commons things you may want to do with your survey.

Conditional display

Let’s say we had a fourth option for “other” in our multiple choice question about penguins. If the respondent chose it, you may want a second question to popup that allows them to specify the other penguin type. To implement this, you would need to define both questions, e.g.:

```{r}
sd_question(
  type  = 'mc',
  id    = 'penguins',
  label = "Which is your favorite type of penguin?",
  option = c(
    'Adélie'    = 'adelie',
    'Chinstrap' = 'chinstrap',
    'Gentoo'    = 'gentoo',
    'Other'     = 'other'
  )
)

sd_question(
  type  = "text",
  id    = "penguins_other",
  label = "Please specify the other penguin type:"
)
```

Then in the server code chunk, you could use the show_if argument to define that the penguins_other question would only be shown if the respondent chose the other option in the penguins question, like this:

config <- sd_config(
  show_if = tibble::tribble(
    ~question_id,  ~question_value, ~target,
    "penguins",    "other",         "penguins_other"
  )
)

This will make the penguins_other question only appear if the respondent chose the other option in the penguins question, like this:


Here we’re using the tibble::tribble() function to define a data frame with three columns:

  • question_id: The id of the triggering question.
  • question_value: The triggering value.
  • target: The id of the target question to display.

You don’t have to use tibble::tribble(), and in fact if you have a lot of show_if conditions, then you could create a csv file with all of your conditions in it and read it in to set the show_if conditions (just make sure the header has the same three column names), e.g.:

config <- sd_config(
  show_if = readr::read_csv('path/to/show_if_conditions.csv')
  )
)

Conditional skip

Often times you’ll want to screen people out of a survey based on responses to questions. For example, let’s say you only wanted to only include people who own a vehicle. On your first page (e.g., with page name welcome), you could screen out people who do not own a vehicle.

First, define a question about their vehicle ownership, e.g.:

```{r}
sd_question(
  type  = 'mc',
  id    = 'vehicle_ownership',
  label = "Do you own your vehicle?",
  option = c(
    'Yes' = 'yes',
    'No'  = 'no'
  )
)
```

Then in the server code chunk, you could use the skip_if argument in sd_config() to define the behavior of the next button on the welcome page, like this:

config <- sd_config(
  skip_if = tibble::tribble(
    ~question_id,        ~question_value, ~target,
    "vehicle_ownership", "no",            "screenout"
  )
)

This sets up a condition where if the respondent chooses no on the vehicle_ownership question, they will be sent to a page named screenout. You could put such a page at the end of the survey, something like this:

::: {#screenout .sd-page}

Sorry, but you are not qualified to take our survey.

:::

Notice that I don’t have a sd_next() on this screenout page. That is how you define an end point for the survey taker. If there’s no “Next” button, then they cannot navigate anywhere else, so the survey is over.

Required responses

By default, no questions are required. However, you can make questions required by adding the question id to the required argument in sd_config(), like this:

config <- sd_config(
  required_questions = c("vehicle_ownership", "penguins_other")
)

This will make the respondent unable to proceed until they have answered the required questions. It will also place a red asterisk (*) next to the question label to indicate that the question is required.

You can also make all questions required by setting all_questions_required = TRUE like this:

config <- sd_config(
  all_questions_required = TRUE
)

Reactivity

One other feature that is particularly powerful is the ability to use R code in your survey via Shiny’s reactive programming. This allows you to make your survey more interactive and to use the full power of R to create custom functionality.

Demo 1: Displaying content based on previous responses

Let’s say you wanted to create a survey that asked the respondent’s name, and then displayed a personalized message based on their name. You could do this by first asking their name:

sd_question(
  type  = "text",
  id    = "name",
  label = "What is your name?"
)

Then you can use the sd_display_value("name") function to display the value of the name question in other parts of your survey. For example:

Welcome, `r sd_display_value("name")`!

Which would render as something like “Welcome, Dave!” (assuming the respondent entered “Dave” in the name question). This works because the sd_display_value() function is reactive, meaning it will update the display based on the respondent’s responses.

Demo 2: Displaying randomized question labels

Let’s say you wanted to show a series of questions, but you wanted to randomize the labels shown for each question. You could do this by first defining a list of labels, and then using the sd_question_reactive() function to create a question that will display a random label from the list.

For example, let’s ask the respondent to rate different car brands from a random set of brands. You could first pre-define the randomized sets of brands for each respondent and store it as a csv file, like this:

brands <- c("Toyota", "Ford", "Chevrolet", "Honda", "Nissan", "Tesla")
design <- data.frame(
  respondent_id = rep(1:10, each = 3),
  brand = unlist(lapply(1:10, function(x) sample(brands, 3, replace = FALSE)))
)
write_csv(design, "design.csv")

This would make a design file that looks like this:

#>   respondent_id     brand
#> 1             1     Honda
#> 2             1     Tesla
#> 3             1    Nissan
#> 4             2     Tesla
#> 5             2      Ford
#> 6             2 Chevrolet

Note that this would not be done in your survey.qmd file - it’s just a one-time thing to create the design (probably stored in an R file).

Then in your server code chunk, you could read in the design file and use it to randomize the labels for each question based on a randomly chosen respondent:

design <- read_csv("design.csv")
resp_id <- sample(design$respondent_id, 1)
df_resp <- design %>% filter(respondent_id == resp_id)

options <- c(1, 2, 3)
names(options) <- df_resp$brand

sd_question_reactive(
  type  = "mc",
  id    = "brands",
  label = "Which of these brands do you like best?",
  option = options
)

Here the sd_question_reactive() function is used because the labels depend on the randomly chosen respondent. This means the labels will be different for each respondent.

Since this is a reactive question, this code must be placed inside the server code chunk, not where you want it to appear in the survey. To define where in the survey the question should appear, you use the sd_display_question() function with the id set to the same value as the id in the sd_question_reactive() function, like this:

sd_display_question(id = "brands")

Now the brands question will be displayed in the survey where you put this code chunk.

Note that all question responses are automatically saved to the database, but if you wanted to store some other value (e.g. the randomly chosen respondent_id), you could do that with the sd_store_value() function, like this:

sd_store_value(resp_id)

How we built it

Before I wrap up, I just want to say that I am absolutely amazed at the time we live in. I have had this idea in mind for many years, but I’m not a web developer, and I never could come up with a way to make it happen. That was actually what motivated me to write my previous blog post - it was a call for help from others!

But two things happened relatively recently that made it possible:

  1. The rise of Quarto
  2. The rise of LLMs

After I switched my website over from distill to Quarto, I began to learn more and more about how powerful Quarto really is for building things on the web. Then I saw the Quarto shiny document framework and I immediately thought that this just might be the missing piece I needed to make surveydown a reality. It does all the legwork of converting markdown and R code into a shiny app.

Of course, implementing this idea was still really, really hard. There were many different ways to start, and I got some excellent feedback from people in the R / Quarto dev community. Garrick Aden-Buie in particular was the first to propose the idea of using fences to denote page breaks, which was a big breakthrough early on.

But the biggest breakthrough came when I started using GPT-4 to help me brainstorm many different ideas while developing the overall platform architecture. This conversation in particular was game changing. In it, I came to solutions for multiple complex problems, including the page navigation logic and which platform to use for the database (we originally started with using Googlesheets, but ultimately decided on Supabase because it is open-source and just far easier to use).

Of course, the AI didn’t do everything. Two of my students, Pingfan Hu and Bogdan Bunea have been instrumental in helping implement many of the features the package now has, and they too have leveraged LLMs to accelerate their problem solving. Thank you guys for all of your hard work! 🙏

It’s been amazing watching this project come together over such a short period of time. The original conversations I had with GPT-4 and others in the R / Quarto community were just in March and April of this year (2024). We really didn’t start developing in earnest until the summer, and really only late June / early July at that. In just a few months, we’ve gone from an idea to a fully functional survey platform.

If you give surveydown a try, please let us know what you think! And if you find a bug or something you wished existed, please post an issue on github.

I’m so excited to see what you all will build with surveydown!

Back to top